Skip to content

Initial update conditions checking #39

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Oct 26, 2023
Merged
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
172 changes: 172 additions & 0 deletions src/condition-check/condition-check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { BigNumber, ethers } from 'ethers';
import {
calculateMedian,
calculateUpdateInPercentage,
checkFulfillmentDataTimestamp,
checkOnchainDataFreshness,
checkDeviationThresholdExceeded,
checkUpdateConditions,
} from './condition-check';
import { getUnixTimestamp } from '../../test/fixtures/utils';
import { HUNDRED_PERCENT } from '../constants';

describe('checkUpdateCondition', () => {
const onChainValue = ethers.BigNumber.from(500);

it('returns true when api value is higher and deviation threshold is reached', () => {
const shouldUpdate = checkDeviationThresholdExceeded(onChainValue, 10, ethers.BigNumber.from(560));

expect(shouldUpdate).toBe(true);
});

it('returns true when api value is lower and deviation threshold is reached', () => {
const shouldUpdate = checkDeviationThresholdExceeded(onChainValue, 10, ethers.BigNumber.from(440));

expect(shouldUpdate).toBe(true);
});

it('returns false when deviation threshold is not reached', () => {
const shouldUpdate = checkDeviationThresholdExceeded(onChainValue, 10, ethers.BigNumber.from(480));

expect(shouldUpdate).toBe(false);
});

it('handles correctly bad JS math', () => {
expect(() => checkDeviationThresholdExceeded(onChainValue, 0.14, ethers.BigNumber.from(560))).not.toThrow();
});

it('checks all update conditions | heartbeat exceeded', () => {
const result = checkUpdateConditions(
BigNumber.from(10),
Date.now() / 1000 - 60 * 60 * 24,
BigNumber.from(10),
Date.now() / 1000,
60 * 60 * 23,
2
);

expect(result).toBe(true);
});

it('checks all update conditions | no update', () => {
const result = checkUpdateConditions(
BigNumber.from(10),
Date.now() / 1000,
BigNumber.from(10),
Date.now() + 60 * 60 * 23,
86_400,
2
);

expect(result).toBe(false);
});
});

describe('checkFulfillmentDataTimestamp', () => {
const onChainData = {
value: ethers.BigNumber.from(10),
timestamp: getUnixTimestamp('2019-4-28'),
};

it('returns true if fulfillment data is newer than on-chain record', () => {
const isFresh = checkFulfillmentDataTimestamp(onChainData.timestamp, getUnixTimestamp('2019-4-29'));
expect(isFresh).toBe(true);
});

it('returns false if fulfillment data is older than on-chain record', () => {
const isFresh = checkFulfillmentDataTimestamp(onChainData.timestamp, getUnixTimestamp('2019-4-27'));
expect(isFresh).toBe(false);
});

it('returns false if fulfillment data has same timestamp with on-chain record', () => {
const isFresh = checkFulfillmentDataTimestamp(onChainData.timestamp, onChainData.timestamp);
expect(isFresh).toBe(false);
});
});

describe('checkOnchainDataFreshness', () => {
it('returns true if on chain data timestamp is newer than heartbeat interval', () => {
const isFresh = checkOnchainDataFreshness(Date.now() / 1000 - 100, 200);

expect(isFresh).toBe(true);
});

it('returns false if on chain data timestamp is older than heartbeat interval', () => {
const isFresh = checkOnchainDataFreshness(Date.now() / 1000 - 300, 200);

expect(isFresh).toBe(false);
});
});

describe('calculateUpdateInPercentage', () => {
it('calculates zero change', () => {
const updateInPercentage = calculateUpdateInPercentage(ethers.BigNumber.from(10), ethers.BigNumber.from(10));
expect(updateInPercentage).toEqual(ethers.BigNumber.from(0 * HUNDRED_PERCENT));
});

it('calculates 100 percent change', () => {
const updateInPercentage = calculateUpdateInPercentage(ethers.BigNumber.from(10), ethers.BigNumber.from(20));
expect(updateInPercentage).toEqual(ethers.BigNumber.from(1 * HUNDRED_PERCENT));
});

it('calculates positive to negative change', () => {
const updateInPercentage = calculateUpdateInPercentage(ethers.BigNumber.from(10), ethers.BigNumber.from(-5));
expect(updateInPercentage).toEqual(ethers.BigNumber.from(1.5 * HUNDRED_PERCENT));
});

it('calculates negative to positive change', () => {
const updateInPercentage = calculateUpdateInPercentage(ethers.BigNumber.from(-5), ethers.BigNumber.from(5));
expect(updateInPercentage).toEqual(ethers.BigNumber.from(2 * HUNDRED_PERCENT));
});

it('calculates initial zero to positive change', () => {
const updateInPercentage = calculateUpdateInPercentage(ethers.BigNumber.from(0), ethers.BigNumber.from(5));
expect(updateInPercentage).toEqual(ethers.BigNumber.from(5 * HUNDRED_PERCENT));
});

it('calculates initial zero to negative change', () => {
const updateInPercentage = calculateUpdateInPercentage(ethers.BigNumber.from(0), ethers.BigNumber.from(-5));
expect(updateInPercentage).toEqual(ethers.BigNumber.from(5 * HUNDRED_PERCENT));
});

it('calculates initial positive to zero change', () => {
const updateInPercentage = calculateUpdateInPercentage(ethers.BigNumber.from(5), ethers.BigNumber.from(0));
expect(updateInPercentage).toEqual(ethers.BigNumber.from(1 * HUNDRED_PERCENT));
});

it('calculates initial negative to zero change', () => {
const updateInPercentage = calculateUpdateInPercentage(ethers.BigNumber.from(-5), ethers.BigNumber.from(0));
expect(updateInPercentage).toEqual(ethers.BigNumber.from(1 * HUNDRED_PERCENT));
});

it('calculates initial negative to negative change', () => {
const updateInPercentage = calculateUpdateInPercentage(ethers.BigNumber.from(-5), ethers.BigNumber.from(-1));
expect(updateInPercentage).toEqual(ethers.BigNumber.from(0.8 * HUNDRED_PERCENT));
});
});

describe('calculateMedian', () => {
describe('for array with odd number of elements', () => {
it('calculates median for sorted array', () => {
const arr = [BigNumber.from(10), BigNumber.from(11), BigNumber.from(24), BigNumber.from(30), BigNumber.from(47)];
expect(calculateMedian(arr)).toEqual(BigNumber.from(24));
});

it('calculates median for unsorted array', () => {
const arr = [BigNumber.from(24), BigNumber.from(11), BigNumber.from(10), BigNumber.from(47), BigNumber.from(30)];
expect(calculateMedian(arr)).toEqual(BigNumber.from(24));
});
});

describe('for array with even number of elements', () => {
it('calculates median for sorted array', () => {
const arr = [BigNumber.from(10), BigNumber.from(11), BigNumber.from(24), BigNumber.from(30)];
expect(calculateMedian(arr)).toEqual(BigNumber.from(17));
});

it('calculates median for unsorted array', () => {
const arr = [BigNumber.from(24), BigNumber.from(11), BigNumber.from(10), BigNumber.from(30)];
expect(calculateMedian(arr)).toEqual(BigNumber.from(17));
});
});
});
89 changes: 89 additions & 0 deletions src/condition-check/condition-check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { ethers } from 'ethers';
import { HUNDRED_PERCENT } from '../constants';
import { logger } from '../logger';

export const calculateUpdateInPercentage = (initialValue: ethers.BigNumber, updatedValue: ethers.BigNumber) => {
const delta = updatedValue.sub(initialValue);
const absoluteDelta = delta.abs();

// Avoid division by 0
const absoluteInitialValue = initialValue.isZero() ? ethers.BigNumber.from(1) : initialValue.abs();

return absoluteDelta.mul(ethers.BigNumber.from(HUNDRED_PERCENT)).div(absoluteInitialValue);
};

export const calculateMedian = (arr: ethers.BigNumber[]) => {
const mid = Math.floor(arr.length / 2);

const nums = [...arr].sort((a, b) => {
if (a.lt(b)) return -1;
else if (a.gt(b)) return 1;
else return 0;
});

return arr.length % 2 === 0 ? nums[mid - 1]!.add(nums[mid]!).div(2) : nums[mid];
};

export const checkDeviationThresholdExceeded = (
onChainValue: ethers.BigNumber,
deviationThreshold: number,
apiValue: ethers.BigNumber
) => {
const updateInPercentage = calculateUpdateInPercentage(onChainValue, apiValue);
const threshold = ethers.BigNumber.from(Math.trunc(deviationThreshold * HUNDRED_PERCENT)).div(
ethers.BigNumber.from(100)
);

return updateInPercentage.gt(threshold);
};

/**
* Returns true when the fulfillment data timestamp is newer than the on chain data timestamp.
*
* Update transaction with stale data would revert on chain, draining the sponsor wallet. See:
* https://github.com/api3dao/airnode-protocol-v1/blob/dev/contracts/dapis/DataFeedServer.sol#L121
* This can happen if the gateway or Airseeker is down and Airkeeper does the updates instead.
*/
export const checkFulfillmentDataTimestamp = (onChainDataTimestamp: number, fulfillmentDataTimestamp: number) =>
onChainDataTimestamp < fulfillmentDataTimestamp;

/**
* Returns true when the on chain data timestamp is newer than the heartbeat interval.
*/
export const checkOnchainDataFreshness = (timestamp: number, heartbeatInterval: number) =>
timestamp > Date.now() / 1000 - heartbeatInterval;

export const checkUpdateConditions = (
onChainValue: ethers.BigNumber,
onChainTimestamp: number,
offChainValue: ethers.BigNumber,
offChainTimestamp: number,
heartbeatInterval: number,
deviationThreshold: number
): boolean => {
// Check that fulfillment data is newer than on chain data
const isFulfillmentDataFresh = checkFulfillmentDataTimestamp(onChainTimestamp, offChainTimestamp);
if (!isFulfillmentDataFresh) {
logger.warn(`Off-chain sample's timestamp is older than on-chain timestamp.`);

return false;
}

// Check that on chain data is newer than heartbeat interval
const isOnchainDataFresh = checkOnchainDataFreshness(onChainTimestamp, heartbeatInterval);
if (isOnchainDataFresh) {
// Check beacon condition
const shouldUpdate = checkDeviationThresholdExceeded(onChainValue, deviationThreshold, offChainValue);
if (shouldUpdate) {
logger.info(`Deviation exceeded.`);

return true;
}
} else {
logger.info(`On-chain timestamp is older than the heartbeat interval.`);

return true;
}

return false;
};
1 change: 1 addition & 0 deletions src/condition-check/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './condition-check';
2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export const HTTP_SIGNED_DATA_API_ATTEMPT_TIMEOUT = 10_000;
export const HTTP_SIGNED_DATA_API_HEADROOM = 1000;

export const HUNDRED_PERCENT = 1e8;
1 change: 1 addition & 0 deletions test/fixtures/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const getUnixTimestamp = (dateString: string) => Math.floor(Date.parse(dateString) / 1000);