Skip to content

Commit

Permalink
Merc 5642 LWBA invariant validation crypto EAs (#3346)
Browse files Browse the repository at this point in the history
* Add LWBA customOutputValidation to elwood. Update integration tests with LWBA validation test

* Add LWBA customOutputValidation to GSR. Update integration tests with LWBA validation test

* Add LWBA customOutputValidation to NCFX. Update integration tests with LWBA validation test

* Add changeset
  • Loading branch information
mjk90 authored Jul 11, 2024
1 parent 33bd0a4 commit 2bd81dc
Show file tree
Hide file tree
Showing 13 changed files with 169 additions and 16 deletions.
7 changes: 7 additions & 0 deletions .changeset/stupid-mugs-complain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@chainlink/elwood-adapter': minor
'@chainlink/ncfx-adapter': minor
'@chainlink/gsr-adapter': minor
---

Added LWBA invariant violation detection
12 changes: 12 additions & 0 deletions packages/sources/elwood/src/endpoint/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import {
DEFAULT_LWBA_ALIASES,
LwbaResponseDataFields,
priceEndpointInputParametersDefinition,
validateLwbaResponse,
} from '@chainlink/external-adapter-framework/adapter'
import { SingleNumberResultResponse } from '@chainlink/external-adapter-framework/util'
import { InputParameters } from '@chainlink/external-adapter-framework/validation'
import { config } from '../config'
import { transport } from '../transport/crypto'
import { AdapterLWBAError } from '@chainlink/external-adapter-framework/validation/error'

const inputParameters = new InputParameters(priceEndpointInputParametersDefinition, [
{
Expand All @@ -29,4 +31,14 @@ export const cryptoEndpoint = new CryptoPriceEndpoint({
aliases: ['crypto', ...DEFAULT_LWBA_ALIASES],
inputParameters,
transport,
customOutputValidation: (output) => {
const data = output.data as LwbaResponseDataFields['Data']
const error = validateLwbaResponse(data.bid, data.mid, data.ask)

if (error) {
throw new AdapterLWBAError({ statusCode: 500, message: error })
}

return undefined
},
})
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,26 @@ exports[`websocket price endpoint should return cached subscribe error 1`] = `
}
`;
exports[`websocket price endpoint should return error (LWBA invariant violation) 1`] = `
{
"error": {
"message": "Invariant violation. Mid price must be between bid and ask prices. Got: (bid: 10001, mid: 10000, ask: 10002)",
"name": "AdapterLWBAError",
},
"status": "errored",
"statusCode": 500,
}
`;
exports[`websocket price endpoint should return success 1`] = `
{
"data": {
"ask": 10002,
"bid": 10001,
"mid": 10000,
"result": 10000,
"bid": 10000,
"mid": 10001,
"result": 10001,
},
"result": 10000,
"result": 10001,
"statusCode": 200,
"timestamps": {
"providerDataReceivedUnixMs": 1024,
Expand Down
23 changes: 20 additions & 3 deletions packages/sources/elwood/test/integration/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ describe('websocket', () => {
base: 'ETH',
quote: 'USD',
}
const dataLWBAInvariantViolation = {
base: 'BTC',
quote: 'USD',
}
beforeAll(async () => {
oldEnv = JSON.parse(JSON.stringify(process.env))
process.env['API_KEY'] = apiKey
Expand All @@ -46,18 +50,31 @@ describe('websocket', () => {

describe('price endpoint', () => {
it('should return success', async () => {
mockSubscribeResponse(apiKey)
mockUnsubscribeResponse(apiKey)
mockSubscribeResponse(apiKey, `${data.base}-${data.quote}`)
mockUnsubscribeResponse(apiKey, `${data.base}-${data.quote}`)
const response = await testAdapter.request(data)
expect(response.json()).toMatchSnapshot()
})

it('should return error (LWBA invariant violation)', async () => {
mockSubscribeResponse(
apiKey,
`${dataLWBAInvariantViolation.base}-${dataLWBAInvariantViolation.quote}`,
)
mockUnsubscribeResponse(
apiKey,
`${dataLWBAInvariantViolation.base}-${dataLWBAInvariantViolation.quote}`,
)
const response = await testAdapter.request(dataLWBAInvariantViolation)
expect(response.json()).toMatchSnapshot()
})

it('should return cached subscribe error', async () => {
mockSubscribeError(apiKey)
const data = {
base: 'XXX',
quote: 'USD',
}
mockSubscribeError(apiKey, `${data.base}-${data.quote}`)
await testAdapter.request(data)
await testAdapter.waitForCache()
const response = await testAdapter.request(data)
Expand Down
33 changes: 25 additions & 8 deletions packages/sources/elwood/test/integration/fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,37 @@
import nock from 'nock'
import { MockWebsocketServer } from '@chainlink/external-adapter-framework/util/testing-utils'

export const mockSubscribeResponse = (apiKey: string) => {
export const mockSubscribeResponse = (apiKey: string, symbol: string) => {
nock(`https://api.chk.elwood.systems`, { encodedQueryParams: true })
.persist()
.post(`/v1/stream?apiKey=${apiKey}`, {
action: 'subscribe',
stream: 'index',
symbol: 'ETH-USD',
symbol,
index_freq: 1000,
})
.reply(200, {}, [])
}

export const mockUnsubscribeResponse = (apiKey: string) => {
export const mockUnsubscribeResponse = (apiKey: string, symbol: string) => {
nock(`https://api.chk.elwood.systems`, { encodedQueryParams: true })
.persist()
.post(`/v1/stream?apiKey=${apiKey}`, {
action: 'unsubscribe',
stream: 'index',
symbol: 'ETH-USD',
symbol,
index_freq: 1000,
})
.reply(200, {}, [])
}

export const mockSubscribeError = (apiKey: string) => {
export const mockSubscribeError = (apiKey: string, symbol: string) => {
nock(`https://api.chk.elwood.systems`, { encodedQueryParams: true })
.persist()
.post(`/v1/stream?apiKey=${apiKey}`, {
action: 'subscribe',
stream: 'index',
symbol: 'XXX-USD',
symbol,
index_freq: 1000,
})
.reply(400, {
Expand All @@ -57,8 +57,8 @@ export const mockWebSocketServer = (URL: string) => {
JSON.stringify({
type: 'Index',
data: {
price: '10000',
bid: '10001',
bid: '10000',
price: '10001',
ask: '10002',
symbol: 'ETH-USD',
timestamp: '2022-11-08T04:18:18.736534617Z',
Expand All @@ -68,6 +68,23 @@ export const mockWebSocketServer = (URL: string) => {
),
10,
)
setTimeout(
() =>
socket.send(
JSON.stringify({
type: 'Index',
data: {
bid: '10001',
price: '10000',
ask: '10002',
symbol: 'BTC-USD',
timestamp: '2022-11-08T04:18:19.736534617Z',
},
sequence: 123,
}),
),
10,
)
}
parseMessage()
})
Expand Down
12 changes: 12 additions & 0 deletions packages/sources/gsr/src/endpoint/price.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import {
DEFAULT_LWBA_ALIASES,
LwbaResponseDataFields,
priceEndpointInputParametersDefinition,
validateLwbaResponse,
} from '@chainlink/external-adapter-framework/adapter'
import { SingleNumberResultResponse } from '@chainlink/external-adapter-framework/util'
import { InputParameters } from '@chainlink/external-adapter-framework/validation'
import { config } from '../config'
import { transport } from '../transport/price'
import { AdapterLWBAError } from '@chainlink/external-adapter-framework/validation/error'

const inputParameters = new InputParameters(priceEndpointInputParametersDefinition, [
{
Expand All @@ -29,4 +31,14 @@ export const endpoint = new CryptoPriceEndpoint({
aliases: ['price-ws', 'crypto', ...DEFAULT_LWBA_ALIASES],
transport,
inputParameters,
customOutputValidation: (output) => {
const data = output.data as LwbaResponseDataFields['Data']
const error = validateLwbaResponse(data.bid, data.mid, data.ask)

if (error) {
throw new AdapterLWBAError({ statusCode: 500, message: error })
}

return undefined
},
})
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`websocket websocket endpoint lwba endpoint should return error (invariant violation) 1`] = `
{
"error": {
"message": "Invariant violation. LWBA response must contain mid, bid and ask prices. Got: (bid: 1233, mid: 1234, ask: undefined)",
"name": "AdapterLWBAError",
},
"status": "errored",
"statusCode": 500,
}
`;

exports[`websocket websocket endpoint lwba endpoint should return success 1`] = `
{
"data": {
Expand Down
10 changes: 10 additions & 0 deletions packages/sources/gsr/test/integration/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ describe('websocket', () => {
quote: 'USD',
endpoint: 'crypto-lwba',
}
const lwbaDataInvariantViolation = {
base: 'BTC',
quote: 'USD',
endpoint: 'crypto-lwba',
}

beforeAll(async () => {
oldEnv = JSON.parse(JSON.stringify(process.env))
Expand Down Expand Up @@ -78,6 +83,11 @@ describe('websocket', () => {
})
})

it('lwba endpoint should return error (invariant violation)', async () => {
const response = await testAdapter.request(lwbaDataInvariantViolation)
expect(response.json()).toMatchSnapshot()
})

it('should return error (empty data)', async () => {
const response = await testAdapter.request({})
expect(response.statusCode).toEqual(400)
Expand Down
15 changes: 15 additions & 0 deletions packages/sources/gsr/test/integration/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ const bidPrice = 1233
const askPrice = 1235
const time = 1669345393482

const basewLwbaInvariantViolation = 'BTC'
const askPriceLwbaInvariantViolation = 1222

export const mockWebSocketServer = (URL: string) => {
const mockWsServer = new MockWebsocketServer(URL, { mock: false })
mockWsServer.on('connection', (socket) => {
Expand All @@ -57,6 +60,18 @@ export const mockWebSocketServer = (URL: string) => {
},
}),
)
socket.send(
JSON.stringify({
type: 'ticker',
data: {
symbol: `${basewLwbaInvariantViolation.toUpperCase()}.${quote.toUpperCase()}`,
price,
bidPrice,
askPriceLwbaInvariantViolation,
ts: time * 1e6,
},
}),
)
})
})
return mockWsServer
Expand Down
12 changes: 12 additions & 0 deletions packages/sources/ncfx/src/endpoint/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
LwbaResponseDataFields,
DEFAULT_LWBA_ALIASES,
priceEndpointInputParametersDefinition,
validateLwbaResponse,
} from '@chainlink/external-adapter-framework/adapter'
import { config } from '../config'
import { InputParameters } from '@chainlink/external-adapter-framework/validation'
Expand All @@ -14,6 +15,7 @@ import {
import {
AdapterError,
AdapterInputError,
AdapterLWBAError,
} from '@chainlink/external-adapter-framework/validation/error'

// Note: this adapter is intended for the API with endpoint 'wss://cryptofeed.ws.newchangefx.com'.
Expand Down Expand Up @@ -54,6 +56,16 @@ export const cryptoEndpoint = new CryptoPriceEndpoint({
aliases: DEFAULT_LWBA_ALIASES,
transport,
customInputValidation,
customOutputValidation: (output) => {
const data = output.data as LwbaResponseDataFields['Data']
const error = validateLwbaResponse(data.bid, data.mid, data.ask)

if (error) {
throw new AdapterLWBAError({ statusCode: 500, message: error })
}

return undefined
},
inputParameters,
requestTransforms: [
(req) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`websocket crypto endpoint should return error (LWBA invariant violation) 1`] = `
{
"error": {
"message": "Invariant violation. Mid price must be between bid and ask prices. Got: (bid: 3106.8495, mid: 3106.9885, ask: 3105.1275)",
"name": "AdapterLWBAError",
},
"status": "errored",
"statusCode": 500,
}
`;

exports[`websocket crypto endpoint should return success 1`] = `
{
"data": {
Expand Down
12 changes: 11 additions & 1 deletion packages/sources/ncfx/test/integration/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ describe('websocket', () => {
base: 'eth',
quote: 'usd',
}
const cryptoDataLwbaInvariantViolation = {
base: 'btc',
quote: 'usd',
}
const forexData = {
base: 'CAD',
quote: 'USD',
Expand Down Expand Up @@ -50,8 +54,9 @@ describe('websocket', () => {

// Send initial request to start background execute and wait for cache to be filled with results
await testAdapter.request(cryptoData)
await testAdapter.request(cryptoDataLwbaInvariantViolation)
await testAdapter.request(forexData)
await testAdapter.waitForCache(2)
await testAdapter.waitForCache(3)
})

afterAll(async () => {
Expand Down Expand Up @@ -82,6 +87,11 @@ describe('websocket', () => {
const response = await testAdapter.request({ base: 'ETH' })
expect(response.statusCode).toEqual(400)
})

it('should return error (LWBA invariant violation)', async () => {
const response = await testAdapter.request(cryptoDataLwbaInvariantViolation)
expect(response.json()).toMatchSnapshot()
})
})

describe('forex endpoint', () => {
Expand Down
8 changes: 8 additions & 0 deletions packages/sources/ncfx/test/integration/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ export const mockCryptoResponse = {
offer: 3107.1275,
mid: 3106.9885,
}
export const mockCryptoResponseLwbaInvariantViolation = {
timestamp: '2022-08-01T07:15:54.909',
currencyPair: 'BTC/USD',
bid: 3106.8495,
offer: 3105.1275,
mid: 3106.9885,
}

export const mockForexResponse = {
USDAED: { price: 3.673, timestamp: '2022-08-01T07:14:54.909Z' },
Expand Down Expand Up @@ -118,6 +125,7 @@ export const mockCryptoWebSocketServer = (URL: string): MockWebsocketServer => {
socket.on('message', () => {
socket.send(JSON.stringify(subscribeResponse))
socket.send(JSON.stringify(mockCryptoResponse))
socket.send(JSON.stringify(mockCryptoResponseLwbaInvariantViolation))
})
})
return mockWsServer
Expand Down

0 comments on commit 2bd81dc

Please sign in to comment.