Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/brown-parks-shine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@chainlink/proof-of-reserves-adapter': patch
---

Add outsideUpdateWindow flag to proof-of-reserves EA response
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -44,6 +48,25 @@ export const execute: ExecuteWithConfig<Config> = 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
adapterNamesV3 as indexerAdaptersV3,
runBalanceAdapter,
} from '../utils/balance'
import { makeOutsideUpdateWindowResponse } from '../utils/outsideUpdateWindow'
import {
adapterNamesV2 as protocolAdaptersV2,
adapterNamesV3 as protocolAdaptersV3,
Expand Down Expand Up @@ -140,9 +141,8 @@ export const execute: ExecuteWithConfig<Config> = 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)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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,
}
`;
189 changes: 189 additions & 0 deletions packages/composites/proof-of-reserves/test/integration/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Test>)
.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<Test>)
.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<Test>)
.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<Test>)
.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 = {
Expand Down
Loading