Skip to content

Commit 07b24de

Browse files
authored
Use multicall to fetch dAPI batches (#56)
* Implement multicall fetching * Create contracts setup * Implement e2e test, fix bugs * Self review * Fix tests and CI
1 parent 3ab9b45 commit 07b24de

17 files changed

+577
-74
lines changed

.github/workflows/main.yml

+2
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ jobs:
6161
cache: 'pnpm'
6262
- name: Install Dependencies
6363
run: pnpm install --frozen-lockfile
64+
- name: Compile contracts
65+
run: pnpm run contracts:compile
6466
- name: Start Hardhat
6567
run: pnpm dev:eth-node&
6668
- name: Test E2E

contracts/contract-imports.sol

+1
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
pragma solidity 0.8.18;
33

44
import "@api3/dapi-management/contracts/DapiDataRegistry.sol";
5+
import "@api3/dapi-management/contracts/HashRegistry.sol";

jest-e2e.config.js

+1
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,6 @@ module.exports = {
1515
testEnvironment: 'jest-environment-node',
1616
testMatch: ['**/?(*.)+(feature).[t]s?(x)'],
1717
testPathIgnorePatterns: ['<rootDir>/.build', '<rootDir>/dist/', '<rootDir>/build/'],
18+
testTimeout: 15_000,
1819
verbose: true,
1920
};

package.json

+2
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,13 @@
2929
"tsc": "tsc --project ."
3030
},
3131
"devDependencies": {
32+
"@api3/airnode-abi": "^0.12.0",
3233
"@api3/ois": "^2.2.0",
3334
"@nomicfoundation/hardhat-network-helpers": "^1.0.9",
3435
"@nomicfoundation/hardhat-toolbox": "^2.0.2",
3536
"@nomiclabs/hardhat-ethers": "^2.2.3",
3637
"@openzeppelin/contracts": "4.9.3",
38+
"@openzeppelin/merkle-tree": "^1.0.5",
3739
"@typechain/ethers-v5": "^10.1.0",
3840
"@typechain/hardhat": "^6.1.2",
3941
"@types/jest": "^29.5.5",

pnpm-lock.yaml

+19-4
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/condition-check/condition-check.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { BigNumber, ethers } from 'ethers';
22

3-
import { getUnixTimestamp } from '../../test/fixtures/utils';
3+
import { getUnixTimestamp } from '../../test/utils';
44
import { HUNDRED_PERCENT } from '../constants';
55

66
import {

src/env/env.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ let env: EnvConfig | undefined;
99
export const loadEnv = () => {
1010
if (env) return env;
1111

12-
dotenv.config({ path: join(__dirname, '../.env') });
12+
dotenv.config({ path: join(__dirname, '../../.env') });
1313

1414
const parseResult = envConfigSchema.safeParse(process.env);
1515
if (!parseResult.success) {

src/update-feeds/dapi-data-registry.ts

+45-1
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,48 @@ import { type DapiDataRegistry, DapiDataRegistry__factory } from '../../typechai
66
export const getDapiDataRegistry = (address: string, provider: ethers.providers.StaticJsonRpcProvider) =>
77
DapiDataRegistry__factory.connect(address, provider);
88

9-
export type ReadDapisResponse = Awaited<ReturnType<DapiDataRegistry['readDapis']>>;
9+
export const verifyMulticallResponse = (
10+
response: Awaited<ReturnType<DapiDataRegistry['callStatic']['tryMulticall']>>
11+
) => {
12+
const { successes, returndata } = response;
13+
14+
if (!successes.every(Boolean)) throw new Error('One of the multicalls failed');
15+
return returndata;
16+
};
17+
18+
export const decodeDapisCountResponse = (dapiDataRegistry: DapiDataRegistry, dapisCountReturndata: string) => {
19+
const dapisCount = dapiDataRegistry.interface.decodeFunctionResult('dapisCount', dapisCountReturndata)[0] as Awaited<
20+
ReturnType<DapiDataRegistry['dapisCount']>
21+
>;
22+
return dapisCount.toNumber();
23+
};
24+
25+
export type DapisCountResponse = ReturnType<typeof decodeDapisCountResponse>;
26+
27+
export const decodeReadDapiWithIndexResponse = (
28+
dapiDataRegistry: DapiDataRegistry,
29+
readDapiWithIndexReturndata: string
30+
) => {
31+
const { dapiName, updateParameters, dataFeedValue, dataFeed, signedApiUrls } =
32+
dapiDataRegistry.interface.decodeFunctionResult('readDapiWithIndex', readDapiWithIndexReturndata) as Awaited<
33+
ReturnType<DapiDataRegistry['readDapiWithIndex']>
34+
>;
35+
36+
// Ethers responses are returned as a combination of array and object. When such object is logged, only the array part
37+
// is logged. To make the logs more readable, we convert the object part to a plain object.
38+
const { deviationReference, deviationThresholdInPercentage, heartbeatInterval } = updateParameters;
39+
const { value, timestamp } = dataFeedValue;
40+
return {
41+
dapiName,
42+
updateParameters: {
43+
deviationReference,
44+
deviationThresholdInPercentage,
45+
heartbeatInterval,
46+
},
47+
dataFeedValue: { value, timestamp },
48+
dataFeed,
49+
signedApiUrls,
50+
};
51+
};
52+
53+
export type ReadDapiWithIndexResponse = ReturnType<typeof decodeReadDapiWithIndexResponse>;

src/update-feeds/update-feeds.test.ts

+39-27
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { ethers } from 'ethers';
22

3-
import { generateMockDapiDataRegistry, generateReadDapisResponse } from '../../test/fixtures/dapi-data-registry';
3+
import {
4+
generateMockDapiDataRegistry,
5+
generateReadDapiWithIndexResponse,
6+
} from '../../test/fixtures/dapi-data-registry';
47
import { allowPartial } from '../../test/utils';
58
import type { DapiDataRegistry } from '../../typechain-types';
69
import type { Chain } from '../config/schema';
@@ -38,7 +41,7 @@ describe(startUpdateFeedLoops.name, () => {
3841

3942
// Expect the intervals to be called with the correct stagger time.
4043
expect(setInterval).toHaveBeenCalledTimes(2);
41-
expect(intervalCalls[1]! - intervalCalls[0]!).toBeGreaterThanOrEqual(40); // Reserving 10s as the buffer for computing stagger time.
44+
expect(intervalCalls[1]! - intervalCalls[0]!).toBeGreaterThanOrEqual(40); // Reserving 10ms as the buffer for computing stagger time.
4245

4346
// Expect the logs to be called with the correct context.
4447
expect(logger.debug).toHaveBeenCalledTimes(3);
@@ -92,21 +95,21 @@ describe(startUpdateFeedLoops.name, () => {
9295

9396
// Expect the logs to be called with the correct context.
9497
expect(logger.debug).toHaveBeenCalledTimes(4);
95-
expect(logger.debug).toHaveBeenCalledWith('Starting update loops for chain', {
98+
expect(logger.debug).toHaveBeenNthCalledWith(1, 'Starting update loops for chain', {
9699
chainId: '123',
97100
staggerTime: 100,
98101
providerNames: ['first-provider'],
99102
});
100-
expect(logger.debug).toHaveBeenCalledWith('Starting update loops for chain', {
103+
expect(logger.debug).toHaveBeenNthCalledWith(2, 'Starting update feed loop', {
104+
chainId: '123',
105+
providerName: 'first-provider',
106+
});
107+
expect(logger.debug).toHaveBeenNthCalledWith(3, 'Starting update loops for chain', {
101108
chainId: '456',
102109
staggerTime: 100,
103110
providerNames: ['another-provider'],
104111
});
105-
expect(logger.debug).toHaveBeenCalledWith('Starting update feed loop', {
106-
chainId: '123',
107-
providerName: 'first-provider',
108-
});
109-
expect(logger.debug).toHaveBeenCalledWith('Starting update feed loop', {
112+
expect(logger.debug).toHaveBeenNthCalledWith(4, 'Starting update feed loop', {
110113
chainId: '456',
111114
providerName: 'another-provider',
112115
});
@@ -119,7 +122,7 @@ describe(runUpdateFeed.name, () => {
119122
jest
120123
.spyOn(dapiDataRegistryModule, 'getDapiDataRegistry')
121124
.mockReturnValue(dapiDataRegistry as unknown as DapiDataRegistry);
122-
dapiDataRegistry.readDapis.mockRejectedValueOnce(new Error('provider-error'));
125+
dapiDataRegistry.callStatic.tryMulticall.mockRejectedValueOnce(new Error('provider-error'));
123126
jest.spyOn(logger, 'error');
124127

125128
await runUpdateFeed(
@@ -145,16 +148,19 @@ describe(runUpdateFeed.name, () => {
145148

146149
it('fetches other batches in a staggered way and logs errors', async () => {
147150
// Prepare the mocked contract so it returns three batches (of size 1) of dAPIs and the second batch fails to load.
148-
const firstBatch = generateReadDapisResponse();
149-
const thirdBatch = generateReadDapisResponse();
151+
const firstBatch = generateReadDapiWithIndexResponse();
152+
const thirdBatch = generateReadDapiWithIndexResponse();
150153
const dapiDataRegistry = generateMockDapiDataRegistry();
151154
jest
152155
.spyOn(dapiDataRegistryModule, 'getDapiDataRegistry')
153156
.mockReturnValue(dapiDataRegistry as unknown as DapiDataRegistry);
154-
dapiDataRegistry.readDapis.mockResolvedValueOnce(firstBatch);
155-
dapiDataRegistry.readDapis.mockRejectedValueOnce(new Error('provider-error'));
156-
dapiDataRegistry.readDapis.mockResolvedValueOnce(thirdBatch);
157-
dapiDataRegistry.dapisCount.mockResolvedValueOnce(ethers.BigNumber.from(3));
157+
dapiDataRegistry.interface.decodeFunctionResult.mockImplementation((_fn, value) => value);
158+
dapiDataRegistry.callStatic.tryMulticall.mockResolvedValueOnce({
159+
successes: [true, true],
160+
returndata: [[ethers.BigNumber.from(3)], firstBatch],
161+
});
162+
dapiDataRegistry.callStatic.tryMulticall.mockResolvedValueOnce({ successes: [false], returndata: [] });
163+
dapiDataRegistry.callStatic.tryMulticall.mockResolvedValueOnce({ successes: [true], returndata: [thirdBatch] });
158164
const sleepCalls = [] as number[];
159165
const originalSleep = utilsModule.sleep;
160166
jest.spyOn(utilsModule, 'sleep').mockImplementation(async (ms) => {
@@ -179,36 +185,42 @@ describe(runUpdateFeed.name, () => {
179185

180186
// Expect the contract to fetch the batches to be called with the correct stagger time.
181187
expect(utilsModule.sleep).toHaveBeenCalledTimes(3);
182-
expect(sleepCalls[0]).toBeGreaterThanOrEqual(40); // Reserving 10s as the buffer for computing stagger time.
188+
expect(sleepCalls[0]).toBeGreaterThanOrEqual(40); // Reserving 10ms as the buffer for computing stagger time.
183189
expect(sleepCalls[1]).toBeGreaterThanOrEqual(0);
184190
expect(sleepCalls[2]).toBe(49.999_999_999_999_99); // Stagger time is actually 150 / 3 = 50, but there is an rounding error.
185191

186192
// Expect the logs to be called with the correct context.
187193
expect(logger.error).toHaveBeenCalledTimes(1);
188-
expect(logger.error).toHaveBeenCalledWith('Failed to get active dAPIs batch', new Error('provider-error'), {
189-
chainId: '123',
190-
providerName: 'provider-name',
191-
});
192-
expect(logger.debug).toHaveBeenCalledTimes(4);
193-
expect(logger.debug).toHaveBeenCalledWith('Fetching first batch of dAPIs batches', {
194+
expect(logger.error).toHaveBeenCalledWith(
195+
'Failed to get active dAPIs batch',
196+
new Error('One of the multicalls failed'),
197+
{
198+
chainId: '123',
199+
providerName: 'provider-name',
200+
}
201+
);
202+
expect(logger.debug).toHaveBeenCalledTimes(6);
203+
expect(logger.debug).toHaveBeenNthCalledWith(1, 'Fetching first batch of dAPIs batches', {
194204
chainId: '123',
195205
providerName: 'provider-name',
196206
});
197-
expect(logger.debug).toHaveBeenCalledWith('Fetching batches of active dAPIs', {
207+
expect(logger.debug).toHaveBeenNthCalledWith(2, 'Processing batch of active dAPIs', expect.anything());
208+
expect(logger.debug).toHaveBeenNthCalledWith(3, 'Fetching batches of active dAPIs', {
198209
batchesCount: 3,
199-
staggerTime: 49.999_999_999_999_99,
200210
chainId: '123',
201211
providerName: 'provider-name',
212+
staggerTime: 49.999_999_999_999_99,
202213
});
203-
expect(logger.debug).toHaveBeenCalledWith('Fetching batch of active dAPIs', {
214+
expect(logger.debug).toHaveBeenNthCalledWith(4, 'Fetching batch of active dAPIs', {
204215
batchIndex: 1,
205216
chainId: '123',
206217
providerName: 'provider-name',
207218
});
208-
expect(logger.debug).toHaveBeenCalledWith('Fetching batch of active dAPIs', {
219+
expect(logger.debug).toHaveBeenNthCalledWith(5, 'Fetching batch of active dAPIs', {
209220
batchIndex: 2,
210221
chainId: '123',
211222
providerName: 'provider-name',
212223
});
224+
expect(logger.debug).toHaveBeenNthCalledWith(6, 'Processing batch of active dAPIs', expect.anything());
213225
});
214226
});

0 commit comments

Comments
 (0)