diff --git a/.changeset/brown-parks-shine.md b/.changeset/brown-parks-shine.md new file mode 100644 index 00000000000..c13cdbda68b --- /dev/null +++ b/.changeset/brown-parks-shine.md @@ -0,0 +1,5 @@ +--- +'@chainlink/proof-of-reserves-adapter': patch +--- + +Add outsideUpdateWindow flag to proof-of-reserves EA response diff --git a/packages/composites/proof-of-reserves/src/endpoint/multiReserves.ts b/packages/composites/proof-of-reserves/src/endpoint/multiReserves.ts index cae194f283e..c9b9b1708c6 100644 --- a/packages/composites/proof-of-reserves/src/endpoint/multiReserves.ts +++ b/packages/composites/proof-of-reserves/src/endpoint/multiReserves.ts @@ -1,6 +1,10 @@ -import type { ExecuteWithConfig, InputParameters } from '@chainlink/ea-bootstrap' +import type { AdapterResponse, ExecuteWithConfig, InputParameters } from '@chainlink/ea-bootstrap' import { AdapterError, Validator } from '@chainlink/ea-bootstrap' import { Config } from '../config' +import { + getOutsideUpdateWindowDetails, + isOutsideUpdateWindowResponse, +} from '../utils/outsideUpdateWindow' import type { TInputParameters as SingleTInputParameters } from './reserves' import { execute as singleExecute } from './reserves' @@ -44,6 +48,25 @@ export const execute: ExecuteWithConfig = async (input, context, config) ), ) + // If any sub-reserve is outside its update window, return a 200/errored + // response with outsideUpdateWindow fields so monitoring can detect a + // planned pause vs an unplanned failure, preserving the existing error shape. + const outsideWindowResult = results.find((r) => isOutsideUpdateWindowResponse(r)) + if (outsideWindowResult) { + const details = getOutsideUpdateWindowDetails(outsideWindowResult) + return { + jobRunID, + status: 'errored', + statusCode: 200, + error: { + name: 'AdapterError', + message: details ?? 'Outside schedule window', + outsideUpdateWindow: true, + outsideUpdateWindowDetails: details, + }, + } as unknown as AdapterResponse + } + const result = results .map((result) => { if (result.statusCode != 200 || !result.result) { diff --git a/packages/composites/proof-of-reserves/src/endpoint/reserves.ts b/packages/composites/proof-of-reserves/src/endpoint/reserves.ts index 56ab9f92e17..01f6851a7f7 100644 --- a/packages/composites/proof-of-reserves/src/endpoint/reserves.ts +++ b/packages/composites/proof-of-reserves/src/endpoint/reserves.ts @@ -8,6 +8,7 @@ import { adapterNamesV3 as indexerAdaptersV3, runBalanceAdapter, } from '../utils/balance' +import { makeOutsideUpdateWindowResponse } from '../utils/outsideUpdateWindow' import { adapterNamesV2 as protocolAdaptersV2, adapterNamesV3 as protocolAdaptersV3, @@ -140,9 +141,8 @@ export const execute: ExecuteWithConfig = async (input, context, config) const currentUTC = new Date() if (currentUTC < startUTC || currentUTC > endUTC) { - throw new Error( - `Skipping request. Current UTC Hour: ${currentUTC} outside schedule window of start: ${startUTC} and end: ${endUTC}`, - ) + const outsideUpdateWindowDetails = `Outside schedule window. Current UTC: ${currentUTC.toISOString()}, window: ${startUTC.toISOString()} - ${endUTC.toISOString()}` + return makeOutsideUpdateWindowResponse(jobRunID, outsideUpdateWindowDetails) } } diff --git a/packages/composites/proof-of-reserves/src/utils/outsideUpdateWindow.ts b/packages/composites/proof-of-reserves/src/utils/outsideUpdateWindow.ts new file mode 100644 index 00000000000..f4eee1a31c3 --- /dev/null +++ b/packages/composites/proof-of-reserves/src/utils/outsideUpdateWindow.ts @@ -0,0 +1,68 @@ +import type { AdapterResponse } from '@chainlink/ea-bootstrap' + +/** + * Response shape emitted when the EA receives a request outside its configured + * update window (startUTC / endUTC). This is an intentional, planned pause — + * distinct from a ripcord signal, which indicates an unplanned data-provider + * outage. + * + * Fields are present at both the top level and inside `data` so that different + * monitoring consumers can find them regardless of where they look. + */ +export type OutsideUpdateWindowResponse = { + jobRunID: string + statusCode: number + result: null + outsideUpdateWindow: true + outsideUpdateWindowDetails: string + data: { + result: null + statusCode: number + outsideUpdateWindow: true + outsideUpdateWindowDetails: string + } +} + +/** + * Build an outside-update-window response for the given job. Returns HTTP 503 + * so the Chainlink node job is considered failed (no stale value written + * on-chain), while the JSON body carries `outsideUpdateWindow: true` for + * monitoring to detect and silence the alert. + * + * This utility is intentionally standalone so that other POR composite or + * source adapters that implement their own update-window logic can import and + * reuse it without duplicating the response shape. + */ +export const makeOutsideUpdateWindowResponse = ( + jobRunID: string, + outsideUpdateWindowDetails: string, +): AdapterResponse => { + const response: OutsideUpdateWindowResponse = { + jobRunID, + statusCode: 503, + result: null, + outsideUpdateWindow: true, + outsideUpdateWindowDetails, + data: { + result: null, + statusCode: 503, + outsideUpdateWindow: true, + outsideUpdateWindowDetails, + }, + } + return response as unknown as AdapterResponse +} + +/** + * Returns true when the given AdapterResponse was produced by + * makeOutsideUpdateWindowResponse. + */ +export const isOutsideUpdateWindowResponse = (response: AdapterResponse): boolean => + (response as unknown as OutsideUpdateWindowResponse).outsideUpdateWindow === true + +/** + * Extracts the detail string from an outside-update-window response, + * or returns undefined if the response is not one. + */ +export const getOutsideUpdateWindowDetails = (response: AdapterResponse): string | undefined => + (response as unknown as OutsideUpdateWindowResponse).outsideUpdateWindowDetails diff --git a/packages/composites/proof-of-reserves/test/integration/__snapshots__/adapter.test.ts.snap b/packages/composites/proof-of-reserves/test/integration/__snapshots__/adapter.test.ts.snap index c7c91bf788f..436c6587207 100644 --- a/packages/composites/proof-of-reserves/test/integration/__snapshots__/adapter.test.ts.snap +++ b/packages/composites/proof-of-reserves/test/integration/__snapshots__/adapter.test.ts.snap @@ -106,6 +106,20 @@ exports[`execute multiReserves endpoint view-function-multi-chain should return } `; +exports[`execute multiReserves endpoint - schedule window should return outsideUpdateWindow response when any sub-reserve is outside schedule window 1`] = ` +{ + "error": { + "message": "Outside schedule window. Current UTC: 2022-01-01T11:11:11.111Z, window: 2022-01-01T12:00:00.000Z - 2022-01-01T13:00:00.000Z", + "name": "AdapterError", + "outsideUpdateWindow": true, + "outsideUpdateWindowDetails": "Outside schedule window. Current UTC: 2022-01-01T11:11:11.111Z, window: 2022-01-01T12:00:00.000Z - 2022-01-01T13:00:00.000Z", + }, + "jobRunID": "1", + "status": "errored", + "statusCode": 200, +} +`; + exports[`execute multiReserves endpoint with scaling should return success 1`] = ` { "data": { @@ -123,3 +137,48 @@ exports[`execute multiReserves endpoint with scaling should return success 1`] = "statusCode": 200, } `; + +exports[`execute reserves endpoint - schedule window should proceed with a normal request when inside the schedule window 1`] = ` +{ + "data": { + "decimals": 8, + "result": "75100045155", + "statusCode": 200, + }, + "jobRunID": "1", + "result": "75100045155", + "statusCode": 200, +} +`; + +exports[`execute reserves endpoint - schedule window should return outsideUpdateWindow response when current time is after schedule window 1`] = ` +{ + "data": { + "outsideUpdateWindow": true, + "outsideUpdateWindowDetails": "Outside schedule window. Current UTC: 2022-01-01T11:11:11.111Z, window: 2022-01-01T09:00:00.000Z - 2022-01-01T10:00:00.000Z", + "result": null, + "statusCode": 503, + }, + "jobRunID": "1", + "outsideUpdateWindow": true, + "outsideUpdateWindowDetails": "Outside schedule window. Current UTC: 2022-01-01T11:11:11.111Z, window: 2022-01-01T09:00:00.000Z - 2022-01-01T10:00:00.000Z", + "result": null, + "statusCode": 503, +} +`; + +exports[`execute reserves endpoint - schedule window should return outsideUpdateWindow response when current time is before schedule window 1`] = ` +{ + "data": { + "outsideUpdateWindow": true, + "outsideUpdateWindowDetails": "Outside schedule window. Current UTC: 2022-01-01T11:11:11.111Z, window: 2022-01-01T12:00:00.000Z - 2022-01-01T13:00:00.000Z", + "result": null, + "statusCode": 503, + }, + "jobRunID": "1", + "outsideUpdateWindow": true, + "outsideUpdateWindowDetails": "Outside schedule window. Current UTC: 2022-01-01T11:11:11.111Z, window: 2022-01-01T12:00:00.000Z - 2022-01-01T13:00:00.000Z", + "result": null, + "statusCode": 503, +} +`; diff --git a/packages/composites/proof-of-reserves/test/integration/adapter.test.ts b/packages/composites/proof-of-reserves/test/integration/adapter.test.ts index 4c696e512f9..253513fb4bd 100644 --- a/packages/composites/proof-of-reserves/test/integration/adapter.test.ts +++ b/packages/composites/proof-of-reserves/test/integration/adapter.test.ts @@ -288,6 +288,195 @@ describe('execute', () => { }) }) + describe('reserves endpoint - schedule window', () => { + // Freeze time to 2022-01-01T11:11:11.111Z (11:11 UTC) for deterministic + // snapshots: outsideUpdateWindowDetails embeds ISO timestamps. + // Use doNotFake to only replace Date – leaving real setTimeout/setInterval + // intact so nock and supertest remain unaffected. + const MOCK_TIME = new Date('2022-01-01T11:11:11.111Z') + + beforeEach(() => { + jest.useFakeTimers({ + now: MOCK_TIME.getTime(), + doNotFake: [ + 'hrtime', + 'nextTick', + 'setImmediate', + 'clearImmediate', + 'setInterval', + 'clearInterval', + 'setTimeout', + 'clearTimeout', + ], + }) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('should return outsideUpdateWindow response when current time is before schedule window', async () => { + // Window 12:00-13:00 UTC; mocked time 11:11 UTC → before start → outsideUpdateWindow. + // No downstream mock needed: response fires before any adapter calls. + const data: AdapterRequest = { + id: '1', + data: { + protocol: 'list', + indexer: 'por_indexer', + addresses: [ + { + address: '39e7mxbeNmRRnjfy1qkphv1TiMcztZ8VuE', + chainId: 'mainnet', + network: 'bitcoin', + }, + ], + startUTC: '1200', + endUTC: '1300', + }, + } + + const response = await (context.req as SuperTest) + .post('/') + .send(data) + .set('Accept', '*/*') + .set('Content-Type', 'application/json') + .expect('Content-Type', /json/) + .expect(503) + expect(response.body).toMatchSnapshot() + }) + + it('should return outsideUpdateWindow response when current time is after schedule window', async () => { + // Window 09:00-10:00 UTC; mocked time 11:11 UTC → after end → outsideUpdateWindow. + const data: AdapterRequest = { + id: '1', + data: { + protocol: 'list', + indexer: 'por_indexer', + addresses: [ + { + address: '39e7mxbeNmRRnjfy1qkphv1TiMcztZ8VuE', + chainId: 'mainnet', + network: 'bitcoin', + }, + ], + startUTC: '0900', + endUTC: '1000', + }, + } + + const response = await (context.req as SuperTest) + .post('/') + .send(data) + .set('Accept', '*/*') + .set('Content-Type', 'application/json') + .expect('Content-Type', /json/) + .expect(503) + expect(response.body).toMatchSnapshot() + }) + + it('should proceed with a normal request when inside the schedule window', async () => { + // Window 10:00-12:00 UTC; mocked time 11:11 UTC → inside → normal request. + mockPoRindexerSuccess() + const data: AdapterRequest = { + id: '1', + data: { + protocol: 'list', + indexer: 'por_indexer', + addresses: [ + { + address: '39e7mxbeNmRRnjfy1qkphv1TiMcztZ8VuE', + chainId: 'mainnet', + network: 'bitcoin', + }, + { + address: '35ULMyVnFoYaPaMxwHTRmaGdABpAThM4QR', + chainId: 'mainnet', + network: 'bitcoin', + }, + ], + startUTC: '1000', + endUTC: '1200', + }, + } + + const response = await (context.req as SuperTest) + .post('/') + .send(data) + .set('Accept', '*/*') + .set('Content-Type', 'application/json') + .expect('Content-Type', /json/) + .expect(200) + expect(response.body).toMatchSnapshot() + }) + }) + + describe('multiReserves endpoint - schedule window', () => { + const MOCK_TIME = new Date('2022-01-01T11:11:11.111Z') + + beforeEach(() => { + jest.useFakeTimers({ + now: MOCK_TIME.getTime(), + doNotFake: [ + 'hrtime', + 'nextTick', + 'setImmediate', + 'clearImmediate', + 'setInterval', + 'clearInterval', + 'setTimeout', + 'clearTimeout', + ], + }) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('should return outsideUpdateWindow response when any sub-reserve is outside schedule window', async () => { + // Window 12:00-13:00 UTC; mocked time 11:11 UTC → before start → outsideUpdateWindow. + // No downstream mocks needed: window check fires before any adapter calls. + const data: AdapterRequest = { + id: '1', + data: { + endpoint: 'multiReserves', + input: [ + { + protocol: 'list', + indexer: 'por_indexer', + addresses: [ + { + address: '39e7mxbeNmRRnjfy1qkphv1TiMcztZ8VuE', + chainId: 'mainnet', + network: 'bitcoin', + }, + ], + startUTC: '1200', + endUTC: '1300', + }, + { + indexer: 'eth_balance', + protocol: 'list', + addresses: ['0x8288C280F35FB8809305906C79BD075962079DD8'], + confirmations: 5, + startUTC: '1200', + endUTC: '1300', + }, + ], + }, + } + + const response = await (context.req as SuperTest) + .post('/') + .send(data) + .set('Accept', '*/*') + .set('Content-Type', 'application/json') + .expect('Content-Type', /json/) + .expect(200) + expect(response.body).toMatchSnapshot() + }) + }) + describe('multiReserves endpoint with scaling', () => { it('should return success', async () => { const data: AdapterRequest = {