From 6c6e2af7806b5a6a689871da1b297ec0b53402e3 Mon Sep 17 00:00:00 2001 From: vponline <65347007+vponline@users.noreply.github.com> Date: Thu, 2 Nov 2023 12:45:14 +0200 Subject: [PATCH] Immer state (#59) * Use timestamp to check for pending txs * Add immer for state manamgement * Refactor setting state * Remove unnecessary draft return, add updateState unit test --- .eslintrc.js | 3 + package.json | 1 + pnpm-lock.yaml | 7 + src/gas-price/gas-price.test.ts | 173 +++--------------- src/gas-price/gas-price.ts | 131 +++---------- src/signed-api-fetch/data-fetcher.test.ts | 3 +- src/signed-api-fetch/data-fetcher.ts | 6 +- .../signed-data-store.test.ts | 4 +- src/signed-data-store/signed-data-store.ts | 27 +-- src/state/state.test.ts | 68 +++++++ src/state/state.ts | 9 + test/e2e/gas-price.feature.ts | 81 ++------ test/fixtures/mock-config.ts | 13 +- 13 files changed, 189 insertions(+), 337 deletions(-) create mode 100644 src/state/state.test.ts diff --git a/.eslintrc.js b/.eslintrc.js index 879363fd..c4085abb 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -27,6 +27,9 @@ module.exports = { '@typescript-eslint/no-var-requires': 'off', '@typescript-eslint/no-dynamic-delete': 'off', + // Lodash + 'lodash/prefer-immutable-method': 'off', + // Jest 'jest/no-hooks': 'off', }, diff --git a/package.json b/package.json index ac09a3b8..8ead21b6 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "axios": "^1.5.1", "dotenv": "^16.3.1", "ethers": "^5.7.2", + "immer": "^10.0.3", "lodash": "^4.17.21", "zod": "^3.22.3" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0f60d0f..fa9c1417 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ dependencies: ethers: specifier: ^5.7.2 version: 5.7.2 + immer: + specifier: ^10.0.3 + version: 10.0.3 lodash: specifier: ^4.17.21 version: 4.17.21 @@ -4669,6 +4672,10 @@ packages: resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} engines: {node: '>= 4'} + /immer@10.0.3: + resolution: {integrity: sha512-pwupu3eWfouuaowscykeckFmVTpqbzW+rXFCX8rQLkZzM9ftBmU/++Ra+o+L27mz03zJTlyV4UUr+fdKNffo4A==} + dev: false + /immutable@4.3.4: resolution: {integrity: sha512-fsXeu4J4i6WNWSikpI88v/PcVflZz+6kMhUfIwc5SY+poQRPnaf5V7qds6SUyUN3cVxEzuCab7QIoLOQ+DQ1wA==} dev: true diff --git a/src/gas-price/gas-price.test.ts b/src/gas-price/gas-price.test.ts index 9b7327d5..48252019 100644 --- a/src/gas-price/gas-price.test.ts +++ b/src/gas-price/gas-price.test.ts @@ -1,7 +1,7 @@ import { ethers } from 'ethers'; import { init } from '../../test/fixtures/mock-config'; -import { getState, setState } from '../state'; +import { getState, updateState } from '../state'; import { getAirseekerRecommendedGasPrice, @@ -53,16 +53,8 @@ describe('gas price', () => { beforeEach(() => { initializeGasStore(chainId, providerName); // Reset the gasPriceStore - const state = getState(); - setState({ - ...state, - gasPriceStore: { - ...state.gasPriceStore, - [chainId]: { - ...state.gasPriceStore[chainId], - [providerName]: { gasPrices: [], sponsorLastUpdateTimestampMs: {} }, - }, - }, + updateState((draft) => { + draft.gasPriceStore[chainId] = { [providerName]: { gasPrices: [], sponsorLastUpdateTimestampMs: {} } }; }); }); @@ -76,19 +68,8 @@ describe('gas price', () => { .spyOn(ethers.providers.StaticJsonRpcProvider.prototype, 'getGasPrice') .mockResolvedValueOnce(ethers.BigNumber.from(gasPriceMock)); - const state = getState(); - setState({ - ...state, - gasPriceStore: { - ...state.gasPriceStore, - [chainId]: { - ...state.gasPriceStore[chainId], - [providerName]: { - ...state.gasPriceStore[chainId]![providerName]!, - gasPrices: [oldGasPriceMock, ...state.gasPriceStore[chainId]![providerName]!.gasPrices], - }, - }, - }, + updateState((draft) => { + draft.gasPriceStore[chainId]![providerName]!.gasPrices.unshift(oldGasPriceMock); }); clearExpiredStoreGasPrices(chainId, providerName, gasSettings.sanitizationSamplingWindow); setStoreGasPrices(chainId, providerName, gasPriceMock); @@ -103,16 +84,8 @@ describe('gas price', () => { beforeEach(() => { initializeGasStore(chainId, providerName); // Reset the gasPriceStore - const state = getState(); - setState({ - ...state, - gasPriceStore: { - ...state.gasPriceStore, - [chainId]: { - ...state.gasPriceStore[chainId], - [providerName]: { gasPrices: [], sponsorLastUpdateTimestampMs: {} }, - }, - }, + updateState((draft) => { + draft.gasPriceStore[chainId] = { [providerName]: { gasPrices: [], sponsorLastUpdateTimestampMs: {} } }; }); }); @@ -134,16 +107,8 @@ describe('gas price', () => { beforeEach(() => { initializeGasStore(chainId, providerName); // Reset the gasPriceStore - const state = getState(); - setState({ - ...state, - gasPriceStore: { - ...state.gasPriceStore, - [chainId]: { - ...state.gasPriceStore[chainId], - [providerName]: { gasPrices: [], sponsorLastUpdateTimestampMs: {} }, - }, - }, + updateState((draft) => { + draft.gasPriceStore[chainId] = { [providerName]: { gasPrices: [], sponsorLastUpdateTimestampMs: {} } }; }); }); @@ -171,19 +136,8 @@ describe('gas price', () => { .spyOn(ethers.providers.StaticJsonRpcProvider.prototype, 'getGasPrice') .mockResolvedValueOnce(ethers.BigNumber.from(gasPriceMock)); - const state = getState(); - setState({ - ...state, - gasPriceStore: { - ...state.gasPriceStore, - [chainId]: { - ...state.gasPriceStore[chainId], - [providerName]: { - ...state.gasPriceStore[chainId]![providerName]!, - gasPrices: [oldGasPriceMock, ...state.gasPriceStore[chainId]![providerName]!.gasPrices], - }, - }, - }, + updateState((draft) => { + draft.gasPriceStore[chainId]![providerName]!.gasPrices.unshift(oldGasPriceMock); }); clearExpiredStoreGasPrices(chainId, providerName, gasSettings.sanitizationSamplingWindow); const gasPrice = await updateGasPriceStore(chainId, providerName, rpcUrl); @@ -222,16 +176,8 @@ describe('gas price', () => { beforeEach(() => { initializeGasStore(chainId, providerName); // Reset the gasPriceStore - const state = getState(); - setState({ - ...state, - gasPriceStore: { - ...state.gasPriceStore, - [chainId]: { - ...state.gasPriceStore[chainId], - [providerName]: { gasPrices: [], sponsorLastUpdateTimestampMs: {} }, - }, - }, + updateState((draft) => { + draft.gasPriceStore[chainId] = { [providerName]: { gasPrices: [], sponsorLastUpdateTimestampMs: {} } }; }); }); @@ -245,19 +191,8 @@ describe('gas price', () => { .spyOn(ethers.providers.StaticJsonRpcProvider.prototype, 'getGasPrice') .mockResolvedValueOnce(ethers.BigNumber.from(gasPriceMock)); - const state = getState(); - setState({ - ...state, - gasPriceStore: { - ...state.gasPriceStore, - [chainId]: { - ...state.gasPriceStore[chainId], - [providerName]: { - ...state.gasPriceStore[chainId]![providerName]!, - gasPrices: [oldGasPriceMock, ...state.gasPriceStore[chainId]![providerName]!.gasPrices], - }, - }, - }, + updateState((draft) => { + draft.gasPriceStore[chainId]![providerName]!.gasPrices.unshift(oldGasPriceMock); }); await gasPriceCollector(chainId, providerName, rpcUrl, gasSettings.sanitizationSamplingWindow); @@ -271,16 +206,8 @@ describe('gas price', () => { beforeEach(() => { initializeGasStore(chainId, providerName); // Reset the gasPriceStore - const state = getState(); - setState({ - ...state, - gasPriceStore: { - ...state.gasPriceStore, - [chainId]: { - ...state.gasPriceStore[chainId], - [providerName]: { gasPrices: [], sponsorLastUpdateTimestampMs: {} }, - }, - }, + updateState((draft) => { + draft.gasPriceStore[chainId] = { [providerName]: { gasPrices: [], sponsorLastUpdateTimestampMs: {} } }; }); }); @@ -314,19 +241,8 @@ describe('gas price', () => { .spyOn(ethers.providers.StaticJsonRpcProvider.prototype, 'getGasPrice') .mockResolvedValueOnce(ethers.BigNumber.from(gasPriceMock)); - const state = getState(); - setState({ - ...state, - gasPriceStore: { - ...state.gasPriceStore, - [chainId]: { - ...state.gasPriceStore[chainId], - [providerName]: { - ...state.gasPriceStore[chainId]![providerName]!, - gasPrices: gasPricesMock, - }, - }, - }, + updateState((draft) => { + draft.gasPriceStore[chainId]![providerName]!.gasPrices = gasPricesMock; }); const gasPrice = await getAirseekerRecommendedGasPrice( chainId, @@ -356,19 +272,8 @@ describe('gas price', () => { .spyOn(ethers.providers.StaticJsonRpcProvider.prototype, 'getGasPrice') .mockResolvedValueOnce(ethers.BigNumber.from(gasPriceMock)); - const state = getState(); - setState({ - ...state, - gasPriceStore: { - ...state.gasPriceStore, - [chainId]: { - ...state.gasPriceStore[chainId], - [providerName]: { - ...state.gasPriceStore[chainId]![providerName]!, - gasPrices: [oldGasPriceMock, ...state.gasPriceStore[chainId]![providerName]!.gasPrices], - }, - }, - }, + updateState((draft) => { + draft.gasPriceStore[chainId]![providerName]!.gasPrices.unshift(oldGasPriceMock); }); const gasPrice = await getAirseekerRecommendedGasPrice( chainId, @@ -396,19 +301,8 @@ describe('gas price', () => { .spyOn(ethers.providers.StaticJsonRpcProvider.prototype, 'getGasPrice') .mockResolvedValueOnce(ethers.BigNumber.from(gasPriceMock)); - const state = getState(); - setState({ - ...state, - gasPriceStore: { - ...state.gasPriceStore, - [chainId]: { - ...state.gasPriceStore[chainId], - [providerName]: { - ...state.gasPriceStore[chainId]![providerName]!, - gasPrices: [oldGasPriceMock, ...state.gasPriceStore[chainId]![providerName]!.gasPrices], - }, - }, - }, + updateState((draft) => { + draft.gasPriceStore[chainId]![providerName]!.gasPrices.unshift(oldGasPriceMock); }); const gasPrice = await getAirseekerRecommendedGasPrice( chainId, @@ -436,23 +330,10 @@ describe('gas price', () => { .spyOn(ethers.providers.StaticJsonRpcProvider.prototype, 'getGasPrice') .mockResolvedValueOnce(ethers.BigNumber.from(gasPriceMock)); - const state = getState(); - setState({ - ...state, - gasPriceStore: { - ...state.gasPriceStore, - [chainId]: { - ...state.gasPriceStore[chainId], - [providerName]: { - ...state.gasPriceStore[chainId]![providerName]!, - gasPrices: [oldGasPriceMock, ...state.gasPriceStore[chainId]![providerName]!.gasPrices], - sponsorLastUpdateTimestampMs: { - ...state.gasPriceStore[chainId]![providerName]!.sponsorLastUpdateTimestampMs, - [sponsorWalletAddress]: timestampMock - gasSettings.scalingWindow * 60 * 1000 - 1, - }, - }, - }, - }, + updateState((draft) => { + draft.gasPriceStore[chainId]![providerName]!.gasPrices.unshift(oldGasPriceMock); + draft.gasPriceStore[chainId]![providerName]!.sponsorLastUpdateTimestampMs[sponsorWalletAddress] = + timestampMock - gasSettings.scalingWindow * 60 * 1000 - 1; }); const gasPrice = await getAirseekerRecommendedGasPrice( chainId, diff --git a/src/gas-price/gas-price.ts b/src/gas-price/gas-price.ts index f03809d5..eb0a9852 100644 --- a/src/gas-price/gas-price.ts +++ b/src/gas-price/gas-price.ts @@ -1,34 +1,19 @@ import { ethers } from 'ethers'; +import { remove } from 'lodash'; import type { GasSettings } from '../config/schema'; -import { getState, setState } from '../state'; +import { getState, updateState } from '../state'; -export const initializeGasStore = (chainId: string, providerName: string) => { - const state = getState(); - - if (!state.gasPriceStore[chainId]) { - setState({ - ...state, - gasPriceStore: { - ...state.gasPriceStore, - [chainId]: {}, - }, - }); - } +export const initializeGasStore = (chainId: string, providerName: string) => + updateState((draft) => { + if (!draft.gasPriceStore[chainId]) { + draft.gasPriceStore[chainId] = {}; + } - if (!state.gasPriceStore[chainId]?.[providerName]) { - setState({ - ...state, - gasPriceStore: { - ...state.gasPriceStore, - [chainId]: { - ...state.gasPriceStore[chainId], - [providerName]: { gasPrices: [], sponsorLastUpdateTimestampMs: {} }, - }, - }, - }); - } -}; + if (!draft.gasPriceStore[chainId]![providerName]) { + draft.gasPriceStore[chainId] = { [providerName]: { gasPrices: [], sponsorLastUpdateTimestampMs: {} } }; + } + }); /** * Saves a gas price into the store. @@ -36,25 +21,10 @@ export const initializeGasStore = (chainId: string, providerName: string) => { * @param providerName * @param gasPrice */ -export const setStoreGasPrices = (chainId: string, providerName: string, gasPrice: ethers.BigNumber) => { - const state = getState(); - setState({ - ...state, - gasPriceStore: { - ...state.gasPriceStore, - [chainId]: { - ...state.gasPriceStore[chainId], - [providerName]: { - ...state.gasPriceStore[chainId]![providerName]!, - gasPrices: [ - { price: gasPrice, timestampMs: Date.now() }, - ...state.gasPriceStore[chainId]![providerName]!.gasPrices, - ], - }, - }, - }, +export const setStoreGasPrices = (chainId: string, providerName: string, gasPrice: ethers.BigNumber) => + updateState((draft) => { + draft.gasPriceStore[chainId]![providerName]!.gasPrices.unshift({ price: gasPrice, timestampMs: Date.now() }); }); -}; /** * Removes gas prices where the timestamp is older than sanitizationSamplingWindow from the store. @@ -62,29 +32,14 @@ export const setStoreGasPrices = (chainId: string, providerName: string, gasPric * @param providerName * @param sanitizationSamplingWindow */ -export const clearExpiredStoreGasPrices = ( - chainId: string, - providerName: string, - sanitizationSamplingWindow: number -) => { - const state = getState(); - // Remove gasPrices older than the sanitizationSamplingWindow - setState({ - ...state, - gasPriceStore: { - ...state.gasPriceStore, - [chainId]: { - ...state.gasPriceStore[chainId], - [providerName]: { - ...state.gasPriceStore[chainId]![providerName]!, - gasPrices: state.gasPriceStore[chainId]![providerName]!.gasPrices.filter( - (gasPrice) => gasPrice.timestampMs >= Date.now() - sanitizationSamplingWindow * 60 * 1000 - ), - }, - }, - }, +export const clearExpiredStoreGasPrices = (chainId: string, providerName: string, sanitizationSamplingWindow: number) => + updateState((draft) => { + // Remove gasPrices older than the sanitizationSamplingWindow + remove( + draft.gasPriceStore[chainId]![providerName]!.gasPrices, + (gasPrice) => gasPrice.timestampMs < Date.now() - sanitizationSamplingWindow * 60 * 1000 + ); }); -}; /** * Saves a sponsor wallet's last update timestamp into the store. @@ -98,22 +53,8 @@ export const setSponsorLastUpdateTimestampMs = ( sponsorWalletAddress: string ) => { initializeGasStore(chainId, providerName); - const state = getState(); - setState({ - ...state, - gasPriceStore: { - ...state.gasPriceStore, - [chainId]: { - ...state.gasPriceStore[chainId], - [providerName]: { - ...state.gasPriceStore[chainId]![providerName]!, - sponsorLastUpdateTimestampMs: { - ...state.gasPriceStore[chainId]![providerName]!.sponsorLastUpdateTimestampMs, - [sponsorWalletAddress]: Date.now(), - }, - }, - }, - }, + updateState((draft) => { + draft.gasPriceStore[chainId]![providerName]!.sponsorLastUpdateTimestampMs[sponsorWalletAddress] = Date.now(); }); }; @@ -127,28 +68,10 @@ export const clearSponsorLastUpdateTimestampMs = ( chainId: string, providerName: string, sponsorWalletAddress: string -) => { - const state = getState(); - - if (state.gasPriceStore[chainId]![providerName]!.sponsorLastUpdateTimestampMs[sponsorWalletAddress]) { - const { [sponsorWalletAddress]: _value, ...sponsorLastUpdateTimestampMs } = - state.gasPriceStore[chainId]![providerName]!.sponsorLastUpdateTimestampMs; - - setState({ - ...state, - gasPriceStore: { - ...state.gasPriceStore, - [chainId]: { - ...state.gasPriceStore[chainId], - [providerName]: { - ...state.gasPriceStore[chainId]![providerName]!, - sponsorLastUpdateTimestampMs, - }, - }, - }, - }); - } -}; +) => + updateState((draft) => { + delete draft.gasPriceStore[chainId]![providerName]!.sponsorLastUpdateTimestampMs[sponsorWalletAddress]; + }); export const getPercentile = (percentile: number, array: ethers.BigNumber[]) => { if (array.length === 0) return; diff --git a/src/signed-api-fetch/data-fetcher.test.ts b/src/signed-api-fetch/data-fetcher.test.ts index 08444451..2a3e9cb5 100644 --- a/src/signed-api-fetch/data-fetcher.test.ts +++ b/src/signed-api-fetch/data-fetcher.test.ts @@ -10,12 +10,11 @@ jest.mock('axios'); describe('data fetcher', () => { beforeEach(() => { + init(); localDataStore.clear(); }); it('retrieves signed data from urls', async () => { - init(); - const setStoreDataPointSpy = jest.spyOn(localDataStore, 'setStoreDataPoint'); mockedAxios.mockResolvedValue( diff --git a/src/signed-api-fetch/data-fetcher.ts b/src/signed-api-fetch/data-fetcher.ts index d37103e9..a190e972 100644 --- a/src/signed-api-fetch/data-fetcher.ts +++ b/src/signed-api-fetch/data-fetcher.ts @@ -6,7 +6,7 @@ import { uniq } from 'lodash'; import { HTTP_SIGNED_DATA_API_ATTEMPT_TIMEOUT, HTTP_SIGNED_DATA_API_HEADROOM } from '../constants'; import * as localDataStore from '../signed-data-store'; -import { getState, setState } from '../state'; +import { getState, updateState } from '../state'; import { signedApiResponseSchema, type SignedData } from '../types'; // Express handler/endpoint path: https://github.com/api3dao/signed-api/blob/b6e0d0700dd9e7547b37eaa65e98b50120220105/packages/api/src/server.ts#L33 @@ -59,7 +59,9 @@ export const runDataFetcher = async () => { if (!dataFetcherInterval) { const dataFetcherInterval = setInterval(runDataFetcher, fetchInterval); - setState({ ...state, dataFetcherInterval }); + updateState((draft) => { + draft.dataFetcherInterval = dataFetcherInterval; + }); } const urls = uniq( diff --git a/src/signed-data-store/signed-data-store.test.ts b/src/signed-data-store/signed-data-store.test.ts index e021e336..c0e2c7e6 100644 --- a/src/signed-data-store/signed-data-store.test.ts +++ b/src/signed-data-store/signed-data-store.test.ts @@ -1,5 +1,6 @@ import { BigNumber, ethers } from 'ethers'; +import { init } from '../../test/fixtures/mock-config'; import { generateRandomBytes32, signData } from '../../test/utils'; import type { SignedData } from '../types'; @@ -11,6 +12,7 @@ describe('datastore', () => { const signer = ethers.Wallet.fromMnemonic('test test test test test test test test test test test junk'); beforeAll(async () => { + init(); const templateId = generateRandomBytes32(); const timestamp = Math.floor((Date.now() - 25 * 60 * 60 * 1000) / 1000).toString(); const airnode = signer.address; @@ -25,7 +27,7 @@ describe('datastore', () => { }; }); - beforeEach(localDataStore.clear); + beforeEach(() => localDataStore.clear); it('stores and gets a data point', () => { // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression diff --git a/src/signed-data-store/signed-data-store.ts b/src/signed-data-store/signed-data-store.ts index 53fd9ff5..7fc9ec14 100644 --- a/src/signed-data-store/signed-data-store.ts +++ b/src/signed-data-store/signed-data-store.ts @@ -2,10 +2,8 @@ import { goSync } from '@api3/promise-utils'; import { ethers } from 'ethers'; import { logger } from '../logger'; -import type { LocalSignedData, SignedData, AirnodeAddress, TemplateId } from '../types'; - -// A simple in-memory data store implementation - the interface allows for swapping in a remote key/value store -let signedApiStore: Record> = {}; +import { getState, updateState } from '../state'; +import type { SignedData, AirnodeAddress, TemplateId } from '../types'; export const verifySignedData = ({ airnode, templateId, timestamp, signature, encodedValue }: SignedData) => { // Verification is wrapped in goSync, because ethers methods can potentially throw on invalid input. @@ -66,11 +64,8 @@ export const setStoreDataPoint = (signedData: SignedData) => { return; } - if (!signedApiStore[airnode]) { - signedApiStore[airnode] = {}; - } - - const existingValue = signedApiStore[airnode]![templateId]; + const state = getState(); + const existingValue = state.signedApiStore[airnode]?.[templateId]; if (existingValue && existingValue.timestamp >= timestamp) { logger.debug('Skipping store update. The existing store value is fresher.'); return; @@ -83,12 +78,20 @@ export const setStoreDataPoint = (signedData: SignedData) => { signature, encodedValue, }); - signedApiStore[airnode]![templateId] = { signature, timestamp, encodedValue }; + updateState((draft) => { + if (!draft.signedApiStore[airnode]) { + draft.signedApiStore[airnode] = {}; + } + + draft.signedApiStore[airnode]![templateId] = { signature, timestamp, encodedValue }; + }); }; export const getStoreDataPoint = (airnode: AirnodeAddress, templateId: TemplateId) => - signedApiStore[airnode]?.[templateId]; + getState().signedApiStore[airnode]?.[templateId]; export const clear = () => { - signedApiStore = {}; + updateState((draft) => { + draft.signedApiStore = {}; + }); }; diff --git a/src/state/state.test.ts b/src/state/state.test.ts new file mode 100644 index 00000000..ccf54f33 --- /dev/null +++ b/src/state/state.test.ts @@ -0,0 +1,68 @@ +import { BigNumber } from 'ethers'; + +import { getConfig } from '../../test/fixtures/mock-config'; + +import { updateState, getState, setState } from './state'; + +const timestampMock = 1_696_930_907_351; +const stateMock = { + config: getConfig(), + gasPriceStore: { + '31337': { + localhost: { + gasPrices: [{ price: BigNumber.from(10), timestampMs: timestampMock }], + sponsorLastUpdateTimestampMs: { '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266': timestampMock }, + }, + }, + }, + signedApiStore: { + '0xC04575A2773Da9Cd23853A69694e02111b2c4182': { + '0x154c34adf151cf4d91b7abe7eb6dcd193104ef2a29738ddc88020a58d6cf6183': { + encodedValue: '0x000000000000000000000000000000000000000000000065954b143faff77440', + signature: + '0x0fe25ad7debe4d018aa53acfe56d84f35c8bedf58574611f5569a8d4415e342311c093bfe0648d54e0a02f13987ac4b033b24220880638df9103a60d4f74090b1c', + timestamp: '1687850583', + }, + }, + }, +}; + +describe('state', () => { + beforeEach(() => { + setState(stateMock); + }); + + it('should update the state correctly', () => { + const stateBefore = getState(); + updateState((draft) => { + draft.signedApiStore['0xc52EeA00154B4fF1EbbF8Ba39FDe37F1AC3B9Fd4'] = {}; + draft.signedApiStore['0xc52EeA00154B4fF1EbbF8Ba39FDe37F1AC3B9Fd4'][ + '0x96504241fb9ae9a5941f97c9561dcfcd7cee77ee9486a58c8e78551c1268ddec' + ] = { + encodedValue: '0x000000000000000000000000000000000000000000000065954b143faff77440', + signature: + '0x0fe25ad7debe4d018aa53acfe56d84f35c8bedf58574611f5569a8d4415e342311c093bfe0648d54e0a02f13987ac4b033b24220880638df9103a60d4f74090b1c', + timestamp: '1687850583', + }; + }); + + const stateAfter = getState(); + + expect(stateBefore).toStrictEqual(stateMock); + expect(stateBefore).not.toStrictEqual(stateAfter); + expect(stateAfter).toStrictEqual({ + ...stateBefore, + signedApiStore: { + ...stateBefore.signedApiStore, + '0xc52EeA00154B4fF1EbbF8Ba39FDe37F1AC3B9Fd4': { + '0x96504241fb9ae9a5941f97c9561dcfcd7cee77ee9486a58c8e78551c1268ddec': { + encodedValue: '0x000000000000000000000000000000000000000000000065954b143faff77440', + signature: + '0x0fe25ad7debe4d018aa53acfe56d84f35c8bedf58574611f5569a8d4415e342311c093bfe0648d54e0a02f13987ac4b033b24220880638df9103a60d4f74090b1c', + timestamp: '1687850583', + }, + }, + }, + }); + }); +}); diff --git a/src/state/state.ts b/src/state/state.ts index 8bad32f6..807c4fb5 100644 --- a/src/state/state.ts +++ b/src/state/state.ts @@ -1,6 +1,8 @@ import type { BigNumber } from 'ethers'; +import { produce, type Draft } from 'immer'; import type { Config } from '../config/schema'; +import type { LocalSignedData, AirnodeAddress, TemplateId } from '../types'; interface GasState { gasPrices: { price: BigNumber; timestampMs: number }[]; @@ -11,8 +13,11 @@ export interface State { config: Config; dataFetcherInterval?: NodeJS.Timeout; gasPriceStore: Record>; + signedApiStore: Record>; } +type StateUpdater = (draft: Draft) => void; + let state: State | undefined; export const getState = (): State => { @@ -26,3 +31,7 @@ export const getState = (): State => { export const setState = (newState: State) => { state = newState; }; + +export const updateState = (updater: StateUpdater) => { + setState(produce(getState(), updater)); +}; diff --git a/test/e2e/gas-price.feature.ts b/test/e2e/gas-price.feature.ts index a5b7c776..8000fca2 100644 --- a/test/e2e/gas-price.feature.ts +++ b/test/e2e/gas-price.feature.ts @@ -7,7 +7,7 @@ import { initializeGasStore, clearExpiredStoreGasPrices, } from '../../src/gas-price/gas-price'; -import { getState, setState } from '../../src/state'; +import { getState, updateState } from '../../src/state'; import { init } from '../fixtures/mock-config'; const chainId = '31337'; @@ -44,16 +44,8 @@ describe(getAirseekerRecommendedGasPrice.name, () => { await network.provider.send('hardhat_reset'); initializeGasStore(chainId, providerName); // Reset the gasPriceStore - const state = getState(); - setState({ - ...state, - gasPriceStore: { - ...state.gasPriceStore, - [chainId]: { - ...state.gasPriceStore[chainId], - [providerName]: { gasPrices: [], sponsorLastUpdateTimestampMs: {} }, - }, - }, + updateState((draft) => { + draft.gasPriceStore[chainId] = { [providerName]: { gasPrices: [], sponsorLastUpdateTimestampMs: {} } }; }); }); @@ -86,19 +78,8 @@ describe(getAirseekerRecommendedGasPrice.name, () => { jest.spyOn(Date, 'now').mockReturnValue(timestampMock); await sendTransaction(); - const state = getState(); - setState({ - ...state, - gasPriceStore: { - ...state.gasPriceStore, - [chainId]: { - ...state.gasPriceStore[chainId], - [providerName]: { - ...state.gasPriceStore[chainId]![providerName]!, - gasPrices: [oldGasPriceMock, ...state.gasPriceStore[chainId]![providerName]!.gasPrices], - }, - }, - }, + updateState((draft) => { + draft.gasPriceStore[chainId]![providerName]!.gasPrices.unshift(oldGasPriceMock); }); const providerRecommendedGasprice = await provider.getGasPrice(); @@ -128,19 +109,9 @@ describe(getAirseekerRecommendedGasPrice.name, () => { price: providerRecommendedGasprice.add(ethers.utils.parseUnits('1', 'gwei')), timestampMs: timestampMock, }; - const state = getState(); - setState({ - ...state, - gasPriceStore: { - ...state.gasPriceStore, - [chainId]: { - ...state.gasPriceStore[chainId], - [providerName]: { - ...state.gasPriceStore[chainId]![providerName]!, - gasPrices: [oldGasPriceMock, ...state.gasPriceStore[chainId]![providerName]!.gasPrices], - }, - }, - }, + + updateState((draft) => { + draft.gasPriceStore[chainId]![providerName]!.gasPrices.unshift(oldGasPriceMock); }); const gasPrice = await getAirseekerRecommendedGasPrice( @@ -170,19 +141,9 @@ describe(getAirseekerRecommendedGasPrice.name, () => { price: oldGasPriceValueMock, timestampMs: timestampMock - 0.9 * gasSettings.sanitizationSamplingWindow * 60 * 1000 - 1, }; - const state = getState(); - setState({ - ...state, - gasPriceStore: { - ...state.gasPriceStore, - [chainId]: { - ...state.gasPriceStore[chainId], - [providerName]: { - ...state.gasPriceStore[chainId]![providerName]!, - gasPrices: [oldGasPriceMock, ...state.gasPriceStore[chainId]![providerName]!.gasPrices], - }, - }, - }, + + updateState((draft) => { + draft.gasPriceStore[chainId]![providerName]!.gasPrices.unshift(oldGasPriceMock); }); const gasPrice = await getAirseekerRecommendedGasPrice( @@ -205,23 +166,9 @@ describe(getAirseekerRecommendedGasPrice.name, () => { await sendTransaction(); const providerRecommendedGasprice = await provider.getGasPrice(); - const state = getState(); - setState({ - ...state, - gasPriceStore: { - ...state.gasPriceStore, - [chainId]: { - ...state.gasPriceStore[chainId], - [providerName]: { - ...state.gasPriceStore[chainId]![providerName]!, - - sponsorLastUpdateTimestampMs: { - ...state.gasPriceStore[chainId]![providerName]!.sponsorLastUpdateTimestampMs, - [sponsorWalletAddress]: timestampMock - gasSettings.scalingWindow * 60 * 1000 - 1, - }, - }, - }, - }, + updateState((draft) => { + draft.gasPriceStore[chainId]![providerName]!.sponsorLastUpdateTimestampMs[sponsorWalletAddress] = + timestampMock - gasSettings.scalingWindow * 60 * 1000 - 1; }); const gasPrice = await getAirseekerRecommendedGasPrice( chainId, diff --git a/test/fixtures/mock-config.ts b/test/fixtures/mock-config.ts index a030ff1c..e4a36316 100644 --- a/test/fixtures/mock-config.ts +++ b/test/fixtures/mock-config.ts @@ -1,7 +1,12 @@ import { ethers } from 'ethers'; import type { Config } from '../../src/config/schema'; -import { setState } from '../../src/state'; +import { setState, type State } from '../../src/state'; + +/** + * A stub to retrieve the latest config + */ +export const getConfig = () => generateTestConfig(); // This is not a secret // https://pool.nodary.io/0xC04575A2773Da9Cd23853A69694e02111b2c4182 @@ -50,10 +55,12 @@ export const generateTestConfig = (): Config => ({ deviationThresholdCoefficient: 1, }); -export const init = () => { - const config = generateTestConfig(); +export const init = (stateOverride?: Partial) => { + const config = getConfig(); setState({ config, gasPriceStore: {}, + signedApiStore: {}, + ...stateOverride, }); };