diff --git a/README.md b/README.md index 7753306..e4b89ef 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,8 @@ type GasOracleOptions = { defaultRpc?: string blocksCount?: number percentile?: number + blockTime?: number // seconds + shouldCache?: boolean fallbackGasPrices?: FallbackGasPrices } @@ -85,6 +87,8 @@ const options: GasOracleOptions = { percentile: 5, // Which percentile of effective priority fees to include blocksCount: 10, // How many blocks to consider for priority fee estimation defaultRpc: 'https://api.mycryptoapi.com/eth', + blockTime: 10, // seconds + shouldCache: false, timeout: 10000, // specifies the number of milliseconds before the request times out. fallbackGasPrices: { gasPrices: { @@ -101,6 +105,14 @@ const options: GasOracleOptions = { } ``` +### The Oracle can cache rpc calls + +For caching needs to provide to GasOracleOptions + +`shouldCache: true` + +`blockTime: ` + ### EIP-1559 (estimated) gasPrice only ```typescript diff --git a/package.json b/package.json index 28ce3ee..ce544f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gas-price-oracle", - "version": "0.5.0", + "version": "0.5.1", "description": "Gas Price Oracle library for Ethereum dApps.", "homepage": "https://github.com/peppersec/gas-price-oracle", "main": "./lib/index.js", @@ -57,7 +57,8 @@ }, "dependencies": { "axios": "^0.21.2", - "bignumber.js": "^9.0.0" + "bignumber.js": "^9.0.0", + "node-cache": "^5.1.2" }, "files": [ "lib/**/*" diff --git a/src/constants/index.ts b/src/constants/index.ts index 9adc545..1bdd5db 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -11,4 +11,17 @@ const INT_PRECISION = 0 const SUCCESS_STATUS = 200 const BG_ZERO = new BigNumber(0) const PERCENT_MULTIPLIER = 100 -export { GWEI, DEFAULT_TIMEOUT, ROUND_UP, ROUND_DOWN, GWEI_PRECISION, INT_PRECISION, SUCCESS_STATUS, BG_ZERO, PERCENT_MULTIPLIER } +const DEFAULT_BLOCK_DURATION = 10 + +export { + GWEI, + DEFAULT_TIMEOUT, + ROUND_UP, + ROUND_DOWN, + GWEI_PRECISION, + INT_PRECISION, + SUCCESS_STATUS, + BG_ZERO, + PERCENT_MULTIPLIER, + DEFAULT_BLOCK_DURATION, +} diff --git a/src/services/cacher/cacheNode.ts b/src/services/cacher/cacheNode.ts new file mode 100644 index 0000000..c504e82 --- /dev/null +++ b/src/services/cacher/cacheNode.ts @@ -0,0 +1,20 @@ +import NodeCache, { Options } from 'node-cache' + +export class NodeJSCache { + private nodeCache: NodeCache + constructor(params: Options) { + this.nodeCache = new NodeCache(params) + } + + async get(key: string): Promise { + return await this.nodeCache.get(key) + } + + async set(key: string, value: T): Promise { + return await this.nodeCache.set(key, value) + } + + async has(key: string): Promise { + return await this.nodeCache.has(key) + } +} diff --git a/src/services/cacher/index.ts b/src/services/cacher/index.ts new file mode 100644 index 0000000..d04b5b2 --- /dev/null +++ b/src/services/cacher/index.ts @@ -0,0 +1 @@ +export * from './cacheNode' diff --git a/src/services/gas-estimation/eip1559.ts b/src/services/gas-estimation/eip1559.ts index baa2052..4ca0464 100644 --- a/src/services/gas-estimation/eip1559.ts +++ b/src/services/gas-estimation/eip1559.ts @@ -3,23 +3,28 @@ import BigNumber from 'bignumber.js' import { FeeHistory, Block } from '@/types' import { Config, EstimateOracle, EstimatedGasPrice, CalculateFeesParams, GasEstimationOptionsPayload } from './types' -import { RpcFetcher } from '@/services' import { ChainId, NETWORKS } from '@/config' -import { BG_ZERO, PERCENT_MULTIPLIER } from '@/constants' +import { RpcFetcher, NodeJSCache } from '@/services' import { findMax, fromNumberToHex, fromWeiToGwei, getMedian } from '@/utils' +import { BG_ZERO, DEFAULT_BLOCK_DURATION, PERCENT_MULTIPLIER } from '@/constants' import { DEFAULT_PRIORITY_FEE, PRIORITY_FEE_INCREASE_BOUNDARY, FEE_HISTORY_BLOCKS, FEE_HISTORY_PERCENTILE } from './constants' // !!! MAKE SENSE ALL CALCULATIONS IN GWEI !!! export class Eip1559GasPriceOracle implements EstimateOracle { public configuration: Config = { + shouldCache: false, chainId: ChainId.MAINNET, + fallbackGasPrices: undefined, + blockTime: DEFAULT_BLOCK_DURATION, blocksCount: NETWORKS[ChainId.MAINNET].blocksCount, percentile: NETWORKS[ChainId.MAINNET].percentile, - fallbackGasPrices: undefined, } private fetcher: RpcFetcher + private cache: NodeJSCache + private FEES_KEY = (chainId: ChainId) => `estimate-fee-${chainId}` + constructor({ fetcher, ...options }: GasEstimationOptionsPayload) { this.fetcher = fetcher const chainId = options?.chainId || this.configuration.chainId @@ -29,10 +34,19 @@ export class Eip1559GasPriceOracle implements EstimateOracle { if (options) { this.configuration = { ...this.configuration, ...options } } + + this.cache = new NodeJSCache({ stdTTL: this.configuration.blockTime, useClones: false }) } public async estimateFees(fallbackGasPrices?: EstimatedGasPrice): Promise { try { + const cacheKey = this.FEES_KEY(this.configuration.chainId) + const cachedFees = await this.cache.get(cacheKey) + + if (cachedFees) { + return cachedFees + } + const { data: latestBlock } = await this.fetcher.makeRpcCall<{ result: Block }>({ method: 'eth_getBlockByNumber', params: ['latest', false], @@ -52,7 +66,12 @@ export class Eip1559GasPriceOracle implements EstimateOracle { params: [blockCount, 'latest', rewardPercentiles], }) - return this.calculateFees({ baseFee, feeHistory: data.result }) + const fees = await this.calculateFees({ baseFee, feeHistory: data.result }) + if (this.configuration.shouldCache) { + await this.cache.set(cacheKey, fees) + } + + return fees } catch (err) { if (fallbackGasPrices) { return fallbackGasPrices diff --git a/src/services/gas-estimation/types.ts b/src/services/gas-estimation/types.ts index c54474f..951cd87 100644 --- a/src/services/gas-estimation/types.ts +++ b/src/services/gas-estimation/types.ts @@ -23,6 +23,8 @@ export type Options = { chainId?: number blocksCount?: number percentile?: number + blockTime?: number + shouldCache?: boolean fallbackGasPrices: EstimatedGasPrice | undefined } diff --git a/src/services/gas-price-oracle/types.ts b/src/services/gas-price-oracle/types.ts index 49b9ed9..180a50d 100644 --- a/src/services/gas-price-oracle/types.ts +++ b/src/services/gas-price-oracle/types.ts @@ -36,6 +36,8 @@ export type GasOracleOptions = { defaultRpc?: string blocksCount?: number percentile?: number + blockTime?: number + shouldCache?: boolean fallbackGasPrices?: FallbackGasPrices } diff --git a/src/services/index.ts b/src/services/index.ts index 89c2f2d..68bf395 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -3,4 +3,5 @@ export * from './gas-price-oracle' export * from './gas-estimation' export * from './legacy-gas-price' +export * from './cacher' export * from './rpcFetcher' diff --git a/src/services/legacy-gas-price/legacy.ts b/src/services/legacy-gas-price/legacy.ts index 514620f..1f55086 100644 --- a/src/services/legacy-gas-price/legacy.ts +++ b/src/services/legacy-gas-price/legacy.ts @@ -14,9 +14,9 @@ import { GetGasPriceFromRespInput, } from './types' -import { RpcFetcher } from '@/services' import { ChainId, NETWORKS } from '@/config' -import { GWEI, DEFAULT_TIMEOUT, GWEI_PRECISION } from '@/constants' +import { RpcFetcher, NodeJSCache } from '@/services' +import { GWEI, DEFAULT_TIMEOUT, GWEI_PRECISION, DEFAULT_BLOCK_DURATION } from '@/constants' import { MULTIPLIERS, DEFAULT_GAS_PRICE } from './constants' @@ -95,22 +95,27 @@ export class LegacyGasPriceOracle implements LegacyOracle { public onChainOracles: OnChainOracles = {} public offChainOracles: OffChainOracles = {} public configuration: Required = { + shouldCache: false, chainId: ChainId.MAINNET, timeout: DEFAULT_TIMEOUT, + blockTime: DEFAULT_BLOCK_DURATION, defaultRpc: NETWORKS[ChainId.MAINNET].rpcUrl, fallbackGasPrices: LegacyGasPriceOracle.getMultipliedPrices(NETWORKS[ChainId.MAINNET].defaultGasPrice), } private readonly fetcher: RpcFetcher + private cache: NodeJSCache + private LEGACY_KEY = (chainId: ChainId) => `legacy-fee-${chainId}` + constructor({ fetcher, ...options }: LegacyOptionsPayload) { this.fetcher = fetcher if (options) { this.configuration = { ...this.configuration, ...options } } - const fallbackGasPrices = - this.configuration.fallbackGasPrices || LegacyGasPriceOracle.getMultipliedPrices(NETWORKS[ChainId.MAINNET].defaultGasPrice) + const { defaultGasPrice } = NETWORKS[ChainId.MAINNET] + const fallbackGasPrices = this.configuration.fallbackGasPrices || LegacyGasPriceOracle.getMultipliedPrices(defaultGasPrice) this.configuration.fallbackGasPrices = LegacyGasPriceOracle.normalize(fallbackGasPrices) const network = NETWORKS[this.configuration.chainId]?.oracles @@ -118,6 +123,8 @@ export class LegacyGasPriceOracle implements LegacyOracle { this.offChainOracles = { ...network.offChainOracles } this.onChainOracles = { ...network.onChainOracles } } + + this.cache = new NodeJSCache({ stdTTL: this.configuration.blockTime, useClones: false }) } public addOffChainOracle(oracle: OffChainOracle): void { @@ -228,9 +235,19 @@ export class LegacyGasPriceOracle implements LegacyOracle { this.lastGasPrice = fallbackGasPrices || this.configuration.fallbackGasPrices } + const cacheKey = this.LEGACY_KEY(this.configuration.chainId) + const cachedFees = await this.cache.get(cacheKey) + + if (cachedFees) { + return cachedFees + } + if (Object.keys(this.offChainOracles).length > 0) { try { this.lastGasPrice = await this.fetchGasPricesOffChain(shouldGetMedian) + if (this.configuration.shouldCache) { + await this.cache.set(cacheKey, this.lastGasPrice) + } return this.lastGasPrice } catch (e) { console.error('Failed to fetch gas prices from offchain oracles...') @@ -240,7 +257,11 @@ export class LegacyGasPriceOracle implements LegacyOracle { if (Object.keys(this.onChainOracles).length > 0) { try { const fastGas = await this.fetchGasPricesOnChain() + this.lastGasPrice = LegacyGasPriceOracle.getCategorize(fastGas) + if (this.configuration.shouldCache) { + await this.cache.set(cacheKey, this.lastGasPrice) + } return this.lastGasPrice } catch (e) { console.error('Failed to fetch gas prices from onchain oracles...') @@ -249,7 +270,11 @@ export class LegacyGasPriceOracle implements LegacyOracle { try { const fastGas = await this.fetchGasPriceFromRpc() + this.lastGasPrice = LegacyGasPriceOracle.getCategorize(fastGas) + if (this.configuration.shouldCache) { + await this.cache.set(cacheKey, this.lastGasPrice) + } return this.lastGasPrice } catch (e) { console.error('Failed to fetch gas prices from default RPC. Last known gas will be returned') diff --git a/src/services/legacy-gas-price/types.ts b/src/services/legacy-gas-price/types.ts index c81759d..bee74d6 100644 --- a/src/services/legacy-gas-price/types.ts +++ b/src/services/legacy-gas-price/types.ts @@ -36,7 +36,9 @@ export type GasPrice = Record export type LegacyOptions = { chainId?: number timeout?: number + blockTime?: number defaultRpc?: string + shouldCache?: boolean fallbackGasPrices?: GasPrice } diff --git a/src/tests/eip1559.test.ts b/src/tests/eip1559.test.ts index 5a21094..3bb74a7 100644 --- a/src/tests/eip1559.test.ts +++ b/src/tests/eip1559.test.ts @@ -10,6 +10,7 @@ import chaiAsPromised from 'chai-as-promised' import mockery from 'mockery' import { before, describe } from 'mocha' +import { sleep } from '@/utils' import { ChainId, NETWORKS } from '@/config' import { GWEI_PRECISION } from '@/constants' @@ -122,6 +123,24 @@ describe('eip-1559 gasOracle', function () { estimateGas.maxFeePerGas.should.be.at.equal(estimatedMaxFee) } }) + it('should cache', async function () { + eipOracle = new GasPriceOracle({ shouldCache: true, chainId }) + const estimateGasFirst: EstimatedGasPrice = await eipOracle.eip1559.estimateFees() + + await sleep(2000) + const estimateGasSecond: EstimatedGasPrice = await eipOracle.eip1559.estimateFees() + + if (estimateGasFirst?.maxFeePerGas) { + estimateGasFirst.maxFeePerGas.should.be.at.equal(estimateGasSecond?.maxFeePerGas) + } + + await sleep(4000) + const estimateGasThird: EstimatedGasPrice = await eipOracle.eip1559.estimateFees() + + if (estimateGasSecond?.maxFeePerGas) { + estimateGasSecond.maxFeePerGas.should.be.at.equal(estimateGasThird?.maxFeePerGas) + } + }) }) }) }) diff --git a/src/tests/legacy.test.ts b/src/tests/legacy.test.ts index da4eebe..5ce31ca 100644 --- a/src/tests/legacy.test.ts +++ b/src/tests/legacy.test.ts @@ -8,6 +8,7 @@ import mockery from 'mockery' import BigNumber from 'bignumber.js' import { before, describe } from 'mocha' +import { sleep } from '@/utils' import { ChainId, NETWORKS } from '@/config' import { DEFAULT_TIMEOUT } from '@/constants' import { GasPriceOracle } from '@/services/gas-price-oracle' @@ -77,6 +78,7 @@ describe('legacy gasOracle', function () { mockery.enable({ useCleanCache: true, warnOnUnregistered: false }) const { GasPriceOracle } = require('../index') oracle = new GasPriceOracle() + // @ts-ignore await oracle.legacy.fetchGasPricesOffChain(true).should.be.rejectedWith('All oracles are down. Probably a network error.') mockery.disable() }) @@ -105,6 +107,7 @@ describe('legacy gasOracle', function () { it('should remove oracle', async function () { await oracle.legacy.fetchGasPricesOnChain() oracle.legacy.removeOnChainOracle('chainlink') + // @ts-ignore await oracle.legacy.fetchGasPricesOnChain().should.be.rejectedWith('All oracles are down. Probably a network error.') }) @@ -113,6 +116,7 @@ describe('legacy gasOracle', function () { await oracle.legacy.fetchGasPricesOnChain() oracle.legacy.removeOnChainOracle('chainlink') + // @ts-ignore await oracle.legacy.fetchGasPricesOnChain().should.be.rejectedWith('All oracles are down. Probably a network error.') oracle.legacy.addOnChainOracle(toAdd) @@ -127,6 +131,7 @@ describe('legacy gasOracle', function () { const { GasPriceOracle } = require('../index') oracle = new GasPriceOracle() + // @ts-ignore await oracle.legacy.fetchGasPricesOnChain().should.be.rejectedWith('All oracles are down. Probably a network error.') mockery.disable() }) @@ -157,6 +162,7 @@ describe('legacy gasOracle', function () { const { GasPriceOracle } = require('../index') oracle = new GasPriceOracle() + // @ts-ignore await oracle.legacy.fetchGasPriceFromRpc().should.be.rejectedWith('Default RPC is down. Probably a network error.') mockery.disable() }) @@ -217,6 +223,25 @@ describe('legacy gasOracle', function () { mockery.disable() }) + + it('should cache', async function () { + const oracle = new GasPriceOracle({ shouldCache: true }) + const gasPricesFirst = await oracle.legacy.gasPrices() + + await sleep(2000) + const gasPricesSecond = await oracle.legacy.gasPrices() + + if (gasPricesFirst.fast) { + gasPricesFirst.fast.should.be.at.equal(gasPricesSecond?.fast) + } + + await sleep(4000) + const gasPricesThird = await oracle.legacy.gasPrices() + + if (gasPricesSecond.fast) { + gasPricesSecond.fast.should.be.at.equal(gasPricesThird?.fast) + } + }) }) describe('median', function () { diff --git a/src/utils/index.ts b/src/utils/index.ts index 7091bed..73e88fb 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,2 +1,8 @@ export * from './math' export * from './crypto' + +const sleep = (time: number): Promise => { + return new Promise((res) => setTimeout(() => res(true), time)) +} + +export { sleep } diff --git a/yarn.lock b/yarn.lock index d5868c9..51f4c5d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -467,6 +467,11 @@ cliui@^5.0.0: strip-ansi "^5.2.0" wrap-ansi "^5.1.0" +clone@2.x: + version "2.1.2" + resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" + integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w== + color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -1520,6 +1525,13 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== +node-cache@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/node-cache/-/node-cache-5.1.2.tgz#f264dc2ccad0a780e76253a694e9fd0ed19c398d" + integrity sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg== + dependencies: + clone "2.x" + node-environment-flags@1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/node-environment-flags/-/node-environment-flags-1.0.6.tgz#a30ac13621f6f7d674260a54dede048c3982c088"