diff --git a/.changeset/graceful-unresolvable-symbols.md b/.changeset/graceful-unresolvable-symbols.md new file mode 100644 index 0000000000..7cd5fa01ff --- /dev/null +++ b/.changeset/graceful-unresolvable-symbols.md @@ -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. diff --git a/.pnp.cjs b/.pnp.cjs index da7bcc6665..b48547e340 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -6839,25 +6839,6 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "HARD"\ }],\ - ["npm:2.11.0", {\ - "packageLocation": "./.yarn/cache/@chainlink-external-adapter-framework-npm-2.11.0-60f407ab19-7a892deca8.zip/node_modules/@chainlink/external-adapter-framework/",\ - "packageDependencies": [\ - ["@chainlink/external-adapter-framework", "npm:2.11.0"],\ - ["@date-fns/tz", "npm:1.4.1"],\ - ["ajv", "npm:8.17.1"],\ - ["axios", "npm:1.13.2"],\ - ["eventsource", "npm:4.0.0"],\ - ["fastify", "npm:5.6.2"],\ - ["ioredis", "npm:5.8.2"],\ - ["mock-socket", "npm:9.3.1"],\ - ["pino", "npm:10.1.0"],\ - ["pino-pretty", "npm:13.1.2"],\ - ["prom-client", "npm:15.1.3"],\ - ["redlock", "npm:5.0.0-beta.2"],\ - ["ws", "virtual:664e8a533bd640fe25950f78953f988492df49452c3758ee6999f93e099115859a376c456a6fbf9c1131444282215c105622c72636d6d7f644610d55a51628d7#npm:8.18.3"]\ - ],\ - "linkType": "HARD"\ - }],\ ["npm:2.11.1", {\ "packageLocation": "./.yarn/cache/@chainlink-external-adapter-framework-npm-2.11.1-3ad4f998bb-5d0ba40462.zip/node_modules/@chainlink/external-adapter-framework/",\ "packageDependencies": [\ @@ -7859,7 +7840,7 @@ const RAW_RUNTIME_STATE = "packageLocation": "./packages/sources/mobula-state/",\ "packageDependencies": [\ ["@chainlink/mobula-state-adapter", "workspace:packages/sources/mobula-state"],\ - ["@chainlink/external-adapter-framework", "npm:2.11.0"],\ + ["@chainlink/external-adapter-framework", "npm:2.11.1"],\ ["@sinonjs/fake-timers", "npm:9.1.2"],\ ["@types/jest", "npm:29.5.14"],\ ["@types/node", "npm:22.14.1"],\ diff --git a/.yarn/cache/@chainlink-external-adapter-framework-npm-2.11.0-60f407ab19-7a892deca8.zip b/.yarn/cache/@chainlink-external-adapter-framework-npm-2.11.0-60f407ab19-7a892deca8.zip deleted file mode 100644 index 6b261e84e7..0000000000 Binary files a/.yarn/cache/@chainlink-external-adapter-framework-npm-2.11.0-60f407ab19-7a892deca8.zip and /dev/null differ diff --git a/packages/sources/mobula-state/package.json b/packages/sources/mobula-state/package.json index edbd769db5..7ade109073 100644 --- a/packages/sources/mobula-state/package.json +++ b/packages/sources/mobula-state/package.json @@ -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" } } diff --git a/packages/sources/mobula-state/src/config/includes.json b/packages/sources/mobula-state/src/config/includes.json index cd2d80c9e1..15707d7d58 100644 --- a/packages/sources/mobula-state/src/config/includes.json +++ b/packages/sources/mobula-state/src/config/includes.json @@ -1335,7 +1335,7 @@ "to": "USD", "includes": [ { - "from": "102502051", + "from": "102479784", "to": "USD", "inverse": false } @@ -2028,7 +2028,7 @@ "to": "USD", "includes": [ { - "from": "102483737", + "from": "102483988", "to": "USD", "inverse": false } diff --git a/packages/sources/mobula-state/src/endpoint/price.ts b/packages/sources/mobula-state/src/endpoint/price.ts index f6cb28b7c7..977d010d2c 100644 --- a/packages/sources/mobula-state/src/endpoint/price.ts +++ b/packages/sources/mobula-state/src/endpoint/price.ts @@ -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 + }, + ], }) diff --git a/packages/sources/mobula-state/src/transport/price.ts b/packages/sources/mobula-state/src/transport/price.ts index f5196b5798..95cdf41211 100644 --- a/packages/sources/mobula-state/src/transport/price.ts +++ b/packages/sources/mobula-state/src/transport/price.ts @@ -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 @@ -41,21 +43,21 @@ const QUOTE_ASSET_IDS: Record = { } // 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 @@ -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 = @@ -114,6 +114,17 @@ export const wsTransport: WebsocketReverseMappingTransport original user params const compositeKey = `${assetId}-${quoteId}` wsTransport.setReverseMapping(compositeKey, params) diff --git a/packages/sources/mobula-state/test/integration/__snapshots__/adapter.test.ts.snap b/packages/sources/mobula-state/test/integration/__snapshots__/adapter.test.ts.snap index 338b363f75..fbc045ab6e 100644 --- a/packages/sources/mobula-state/test/integration/__snapshots__/adapter.test.ts.snap +++ b/packages/sources/mobula-state/test/integration/__snapshots__/adapter.test.ts.snap @@ -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, }, } `; @@ -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, }, } `; @@ -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": { @@ -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, }, @@ -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, }, @@ -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, }, @@ -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, }, @@ -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, }, @@ -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, }, @@ -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, }, @@ -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, }, @@ -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, }, diff --git a/packages/sources/mobula-state/test/integration/adapter.test.ts b/packages/sources/mobula-state/test/integration/adapter.test.ts index f144fcf772..2aa5bfd39c 100644 --- a/packages/sources/mobula-state/test/integration/adapter.test.ts +++ b/packages/sources/mobula-state/test/integration/adapter.test.ts @@ -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) @@ -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() + }) + }) }) diff --git a/packages/sources/mobula-state/test/integration/fixtures.ts b/packages/sources/mobula-state/test/integration/fixtures.ts index b0fd2963fc..cca16cd968 100644 --- a/packages/sources/mobula-state/test/integration/fixtures.ts +++ b/packages/sources/mobula-state/test/integration/fixtures.ts @@ -41,6 +41,48 @@ export const mockWebsocketServer = (URL: string): MockWebsocketServer => { quoteID: '100004304', }, ], + // BTC responses + '100001656': [ + { + timestamp: 1514764861000 + 500, + price: 96234.56, + marketDepthUSDUp: 5000000000, + marketDepthUSDDown: 4800000000, + volume24h: 50000000000, + baseSymbol: 'BTC', + quoteSymbol: 'USD', + baseID: '100001656', + quoteID: 'USD', + }, + ], + // ETH responses + '100004304': [ + { + timestamp: 1514764861000 + 500, + price: 3456.78, + marketDepthUSDUp: 2000000000, + marketDepthUSDDown: 1900000000, + volume24h: 20000000000, + baseSymbol: 'ETH', + quoteSymbol: 'USD', + baseID: '100004304', + quoteID: 'USD', + }, + ], + // RSETH responses + '102479784': [ + { + timestamp: 1514764861000 + 500, + price: 2950.12, + marketDepthUSDUp: 1500000000, + marketDepthUSDDown: 1400000000, + volume24h: 15000000000, + baseSymbol: 'RSETH', + quoteSymbol: 'USD', + baseID: '102479784', + quoteID: 'USD', + }, + ], // CBETH responses '100029813': [ { @@ -149,6 +191,10 @@ export const mockWebsocketServer = (URL: string): MockWebsocketServer => { mockWsServer.on('connection', (socket) => { socket.on('message', (message) => { + // Skip if message is undefined (happens when subscribeMessage returns undefined) + if (!message || message === 'undefined') { + return + } const parsed = JSON.parse(message as string) // Handle Mobula v2 price subscriptions (asset_ids based) diff --git a/yarn.lock b/yarn.lock index 7429d04866..db7985ba5d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3975,28 +3975,6 @@ __metadata: languageName: node linkType: hard -"@chainlink/external-adapter-framework@npm:2.11.0": - version: 2.11.0 - resolution: "@chainlink/external-adapter-framework@npm:2.11.0" - dependencies: - "@date-fns/tz": "npm:1.4.1" - ajv: "npm:8.17.1" - axios: "npm:1.13.2" - eventsource: "npm:4.0.0" - fastify: "npm:5.6.2" - ioredis: "npm:5.8.2" - mock-socket: "npm:9.3.1" - pino: "npm:10.1.0" - pino-pretty: "npm:13.1.2" - prom-client: "npm:15.1.3" - redlock: "npm:5.0.0-beta.2" - ws: "npm:8.18.3" - bin: - create-external-adapter: adapter-generator.js - checksum: 10/7a892deca87eacad95fe7032f92bbea70e52ead8f03041d40f5c159095874f9dba501ddd37d7404a9e2a9d091150cdd862f799a821ccd264016b7c18c0daef1d - languageName: node - linkType: hard - "@chainlink/external-adapter-framework@npm:2.11.1": version: 2.11.1 resolution: "@chainlink/external-adapter-framework@npm:2.11.1" @@ -4899,7 +4877,7 @@ __metadata: version: 0.0.0-use.local resolution: "@chainlink/mobula-state-adapter@workspace:packages/sources/mobula-state" dependencies: - "@chainlink/external-adapter-framework": "npm:2.11.0" + "@chainlink/external-adapter-framework": "npm:2.11.1" "@sinonjs/fake-timers": "npm:9.1.2" "@types/jest": "npm:^29.5.14" "@types/node": "npm:22.14.1"