Skip to content

Commit d63bb4f

Browse files
authored
OPDATA-4430: Handle missing adapter in implied-price (#4055)
1 parent b678a5c commit d63bb4f

File tree

4 files changed

+223
-10
lines changed

4 files changed

+223
-10
lines changed

.changeset/late-shoes-admire.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@chainlink/implied-price-adapter': minor
3+
---
4+
5+
Handle missing adapters

packages/composites/implied-price/src/endpoint/computedPrice.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
} from '@chainlink/ea-bootstrap'
88
import {
99
AdapterError,
10+
AdapterInputError,
1011
AdapterResponseInvalidError,
1112
Requester,
1213
util,
@@ -75,6 +76,37 @@ export const execute: ExecuteWithConfig<Config> = (input, _, config) => {
7576
return executeComputedPrice(validator.validated.id, validator.validated.data, config)
7677
}
7778

79+
export const getOperandSourceUrls = ({
80+
sources,
81+
minAnswers,
82+
}: {
83+
sources: string[]
84+
minAnswers: number
85+
}): string[] => {
86+
if (sources.length < minAnswers) {
87+
throw new AdapterInputError({
88+
statusCode: 400,
89+
message: `Not enough sources: got ${sources.length} sources, requiring at least ${minAnswers} answers`,
90+
})
91+
}
92+
const urls = sources
93+
.map((source) => util.getURL(source.toUpperCase()))
94+
.filter((url) => url !== undefined)
95+
const missingUrlCount = minAnswers - urls.length
96+
if (missingUrlCount > 0) {
97+
const missingEnvVars = sources
98+
.map((source) => `${source.toUpperCase()}_${util.ENV_ADAPTER_URL}`)
99+
.filter((envVar) => util.getEnv(envVar) === undefined)
100+
throw new AdapterError({
101+
statusCode: 500,
102+
message: `Not enough sources configured. Make sure ${missingUrlCount} of the following are set in the environment: ${missingEnvVars.join(
103+
', ',
104+
)}`,
105+
})
106+
}
107+
return urls
108+
}
109+
78110
export const executeComputedPrice = async (
79111
validatedId: string,
80112
validatedData: TInputParameters,
@@ -90,7 +122,10 @@ export const executeComputedPrice = async (
90122
const operation = validatedData.operation.toLowerCase()
91123
// TODO: non-nullable default types
92124

93-
const operand1Urls = operand1Sources.map((source) => util.getRequiredURL(source.toUpperCase()))
125+
const operand1Urls = getOperandSourceUrls({
126+
sources: operand1Sources,
127+
minAnswers: operand1MinAnswers,
128+
})
94129
const operand1Result = await getExecuteMedian(
95130
jobRunID,
96131
operand1Urls,
@@ -102,7 +137,10 @@ export const executeComputedPrice = async (
102137
throw new AdapterResponseInvalidError({ message: 'Operand 1 result is zero' })
103138
}
104139

105-
const operand2Urls = operand2Sources.map((source) => util.getRequiredURL(source.toUpperCase()))
140+
const operand2Urls = getOperandSourceUrls({
141+
sources: operand2Sources,
142+
minAnswers: operand2MinAnswers,
143+
})
106144
const operand2Result = await getExecuteMedian(
107145
jobRunID,
108146
operand2Urls,

packages/composites/implied-price/test/integration/__snapshots__/adapter.test.ts.snap

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,35 @@
11
// Jest Snapshot v1, https://goo.gl/fbAQLP
22

3-
exports[`impliedPrice with endpoint computedPrice erroring calls returns error if not reaching minAnswers 1`] = `
3+
exports[`impliedPrice with endpoint computedPrice erroring calls returns error if not enough configured sources to reach minAnswers 1`] = `
4+
{
5+
"error": {
6+
"feedID": "{"data":{"endpoint":"computedPrice","operand1Sources":["coingecko","not_configured_1","not_configured_2"],"operand1MinAnswers":2,"operand2Sources":["coingecko"],"operand1Input":{"from":"LINK","to":"USD"},"operand2Input":{"from":"ETH","to":"USD"},"operation":"divide"}}",
7+
"message": "Not enough sources configured. Make sure 1 of the following are set in the environment: NOT_CONFIGURED_1_ADAPTER_URL, NOT_CONFIGURED_2_ADAPTER_URL",
8+
"name": "AdapterError",
9+
},
10+
"jobRunID": "1",
11+
"status": "errored",
12+
"statusCode": 500,
13+
}
14+
`;
15+
16+
exports[`impliedPrice with endpoint computedPrice erroring calls returns error if not enough sources to reach minAnswers 1`] = `
417
{
518
"error": {
619
"feedID": "{"data":{"endpoint":"computedPrice","operand1Sources":["coingecko"],"operand1MinAnswers":2,"operand2Sources":["coingecko"],"operand1Input":{"from":"LINK","to":"USD"},"operand2Input":{"from":"ETH","to":"USD"},"operation":"divide"}}",
20+
"message": "Not enough sources: got 1 sources, requiring at least 2 answers",
21+
"name": "AdapterError",
22+
},
23+
"jobRunID": "1",
24+
"status": "errored",
25+
"statusCode": 400,
26+
}
27+
`;
28+
29+
exports[`impliedPrice with endpoint computedPrice erroring calls returns error if not reaching minAnswers 1`] = `
30+
{
31+
"error": {
32+
"feedID": "{"data":{"endpoint":"computedPrice","operand1Sources":["coingecko","failing"],"operand1MinAnswers":2,"operand2Sources":["coingecko"],"operand1Input":{"from":"LINK","to":"USD"},"operand2Input":{"from":"ETH","to":"USD"},"operation":"divide"}}",
733
"message": "Not returning median: got 1 answers, requiring min. 2 answers",
834
"name": "AdapterError",
935
},
@@ -91,8 +117,8 @@ exports[`impliedPrice with endpoint computedPrice validation error returns a val
91117
{
92118
"error": {
93119
"feedID": "{"data":{"endpoint":"computedPrice","operand1Sources":["NOT_REAL"],"operand2Sources":["coingecko"],"operand1Input":{"from":"LINK","to":"USD"},"operand2Input":{"from":"ETH","to":"USD"},"operation":"divide"}}",
94-
"message": "Please set the required env NOT_REAL_ADAPTER_URL.",
95-
"name": "RequiredEnvError",
120+
"message": "Not enough sources configured. Make sure 1 of the following are set in the environment: NOT_REAL_ADAPTER_URL",
121+
"name": "AdapterError",
96122
},
97123
"jobRunID": "1",
98124
"status": "errored",
@@ -178,10 +204,36 @@ exports[`impliedPrice with endpoint impliedPrice erroring calls returns error if
178204
}
179205
`;
180206

181-
exports[`impliedPrice with endpoint impliedPrice erroring calls returns error if not reaching minAnswers 1`] = `
207+
exports[`impliedPrice with endpoint impliedPrice erroring calls returns error if not enough configured sources to reach minAnswers 1`] = `
208+
{
209+
"error": {
210+
"feedID": "{"data":{"endpoint":"impliedPrice","dividendSources":["coingecko","not_configured_1","not_configured_2"],"dividendMinAnswers":2,"divisorSources":["coingecko"],"dividendInput":{"from":"LINK","to":"USD"},"divisorInput":{"from":"ETH","to":"USD"}}}",
211+
"message": "Not enough sources configured. Make sure 1 of the following are set in the environment: NOT_CONFIGURED_1_ADAPTER_URL, NOT_CONFIGURED_2_ADAPTER_URL",
212+
"name": "AdapterError",
213+
},
214+
"jobRunID": "1",
215+
"status": "errored",
216+
"statusCode": 500,
217+
}
218+
`;
219+
220+
exports[`impliedPrice with endpoint impliedPrice erroring calls returns error if not enough sources to reach minAnswers 1`] = `
182221
{
183222
"error": {
184223
"feedID": "{"data":{"endpoint":"impliedPrice","dividendSources":["coingecko"],"dividendMinAnswers":2,"divisorSources":["coingecko"],"dividendInput":{"from":"LINK","to":"USD"},"divisorInput":{"from":"ETH","to":"USD"}}}",
224+
"message": "Not enough sources: got 1 sources, requiring at least 2 answers",
225+
"name": "AdapterError",
226+
},
227+
"jobRunID": "1",
228+
"status": "errored",
229+
"statusCode": 400,
230+
}
231+
`;
232+
233+
exports[`impliedPrice with endpoint impliedPrice erroring calls returns error if not reaching minAnswers 1`] = `
234+
{
235+
"error": {
236+
"feedID": "{"data":{"endpoint":"impliedPrice","dividendSources":["coingecko","failing"],"dividendMinAnswers":2,"divisorSources":["coingecko"],"dividendInput":{"from":"LINK","to":"USD"},"divisorInput":{"from":"ETH","to":"USD"}}}",
185237
"message": "Not returning median: got 1 answers, requiring min. 2 answers",
186238
"name": "AdapterError",
187239
},
@@ -219,8 +271,8 @@ exports[`impliedPrice with endpoint impliedPrice validation error returns a vali
219271
{
220272
"error": {
221273
"feedID": "{"data":{"endpoint":"impliedPrice","dividendSources":["NOT_REAL"],"divisorSources":["coingecko"],"dividendInput":{"from":"LINK","to":"USD"},"divisorInput":{"from":"ETH","to":"USD"}}}",
222-
"message": "Please set the required env NOT_REAL_ADAPTER_URL.",
223-
"name": "RequiredEnvError",
274+
"message": "Not enough sources configured. Make sure 1 of the following are set in the environment: NOT_REAL_ADAPTER_URL",
275+
"name": "AdapterError",
224276
},
225277
"jobRunID": "1",
226278
"status": "errored",

packages/composites/implied-price/test/integration/adapter.test.ts

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ describe('impliedPrice', () => {
161161
describe('erroring calls', () => {
162162
const jobID = '1'
163163

164-
it('returns error if not reaching minAnswers', async () => {
164+
it('returns error if not enough sources to reach minAnswers', async () => {
165165
mockSuccessfulResponseCoingecko()
166166
const data: AdapterRequest = {
167167
id: jobID,
@@ -181,6 +181,66 @@ describe('impliedPrice', () => {
181181
operation: 'divide',
182182
},
183183
}
184+
const response = await (context.req as SuperTest<Test>)
185+
.post('/')
186+
.send(data)
187+
.set('Accept', '*/*')
188+
.set('Content-Type', 'application/json')
189+
.expect('Content-Type', /json/)
190+
.expect(400)
191+
expect(response.body).toMatchSnapshot()
192+
})
193+
194+
it('returns error if not enough configured sources to reach minAnswers', async () => {
195+
mockSuccessfulResponseCoingecko()
196+
const data: AdapterRequest = {
197+
id: jobID,
198+
data: {
199+
endpoint,
200+
operand1Sources: ['coingecko', 'not_configured_1', 'not_configured_2'],
201+
operand1MinAnswers: 2,
202+
operand2Sources: ['coingecko'],
203+
operand1Input: {
204+
from: 'LINK',
205+
to: 'USD',
206+
},
207+
operand2Input: {
208+
from: 'ETH',
209+
to: 'USD',
210+
},
211+
operation: 'divide',
212+
},
213+
}
214+
const response = await (context.req as SuperTest<Test>)
215+
.post('/')
216+
.send(data)
217+
.set('Accept', '*/*')
218+
.set('Content-Type', 'application/json')
219+
.expect('Content-Type', /json/)
220+
.expect(500)
221+
expect(response.body).toMatchSnapshot()
222+
})
223+
224+
it('returns error if not reaching minAnswers', async () => {
225+
mockSuccessfulResponseCoingecko()
226+
const data: AdapterRequest = {
227+
id: jobID,
228+
data: {
229+
endpoint,
230+
operand1Sources: ['coingecko', 'failing'],
231+
operand1MinAnswers: 2,
232+
operand2Sources: ['coingecko'],
233+
operand1Input: {
234+
from: 'LINK',
235+
to: 'USD',
236+
},
237+
operand2Input: {
238+
from: 'ETH',
239+
to: 'USD',
240+
},
241+
operation: 'divide',
242+
},
243+
}
184244
const response = await (context.req as SuperTest<Test>)
185245
.post('/')
186246
.send(data)
@@ -447,7 +507,7 @@ describe('impliedPrice', () => {
447507
describe('erroring calls', () => {
448508
const jobID = '1'
449509

450-
it('returns error if not reaching minAnswers', async () => {
510+
it('returns error if not enough sources to reach minAnswers', async () => {
451511
mockSuccessfulResponseCoingecko()
452512
const data: AdapterRequest = {
453513
id: jobID,
@@ -466,6 +526,64 @@ describe('impliedPrice', () => {
466526
},
467527
},
468528
}
529+
const response = await (context.req as SuperTest<Test>)
530+
.post('/')
531+
.send(data)
532+
.set('Accept', '*/*')
533+
.set('Content-Type', 'application/json')
534+
.expect('Content-Type', /json/)
535+
.expect(400)
536+
expect(response.body).toMatchSnapshot()
537+
})
538+
539+
it('returns error if not enough configured sources to reach minAnswers', async () => {
540+
mockSuccessfulResponseCoingecko()
541+
const data: AdapterRequest = {
542+
id: jobID,
543+
data: {
544+
endpoint,
545+
dividendSources: ['coingecko', 'not_configured_1', 'not_configured_2'],
546+
dividendMinAnswers: 2,
547+
divisorSources: ['coingecko'],
548+
dividendInput: {
549+
from: 'LINK',
550+
to: 'USD',
551+
},
552+
divisorInput: {
553+
from: 'ETH',
554+
to: 'USD',
555+
},
556+
},
557+
}
558+
const response = await (context.req as SuperTest<Test>)
559+
.post('/')
560+
.send(data)
561+
.set('Accept', '*/*')
562+
.set('Content-Type', 'application/json')
563+
.expect('Content-Type', /json/)
564+
.expect(500)
565+
expect(response.body).toMatchSnapshot()
566+
})
567+
568+
it('returns error if not reaching minAnswers', async () => {
569+
mockSuccessfulResponseCoingecko()
570+
const data: AdapterRequest = {
571+
id: jobID,
572+
data: {
573+
endpoint,
574+
dividendSources: ['coingecko', 'failing'],
575+
dividendMinAnswers: 2,
576+
divisorSources: ['coingecko'],
577+
dividendInput: {
578+
from: 'LINK',
579+
to: 'USD',
580+
},
581+
divisorInput: {
582+
from: 'ETH',
583+
to: 'USD',
584+
},
585+
},
586+
}
469587
const response = await (context.req as SuperTest<Test>)
470588
.post('/')
471589
.send(data)

0 commit comments

Comments
 (0)