diff --git a/src/config.ts b/src/config.ts index ad1415b..b3956c3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,3 +23,4 @@ export const GAS_STATION_API_CHAIN_ID = 1; export const GAS_STATION_API_URL = "https://ethgasstation.info/json/ethgasAPI.json"; export const GAS_STATION_API_REQUEST_TIMEOUT_MS = 10000; +export const MAX_GAS_PRICE_GWEI = 1000; diff --git a/src/gas-price/gas-price-provider.ts b/src/gas-price/gas-price-provider.ts index c65e8a5..4d96b53 100644 --- a/src/gas-price/gas-price-provider.ts +++ b/src/gas-price/gas-price-provider.ts @@ -15,6 +15,7 @@ import { GAS_STATION_API_CHAIN_ID, GAS_STATION_API_REQUEST_TIMEOUT_MS, GAS_STATION_API_URL, + MAX_GAS_PRICE_GWEI, } from "../config"; import GasStationGasPriceProvider from "./providers/gas-station-gas-price-provider"; import ProviderGasPriceProvider from "./providers/provider-gas-price-provider"; @@ -24,6 +25,7 @@ import Eip1559GasPriceProvider, { Eip1559Profile, } from "./providers/eip1559-gas-price-provider"; import { BigNumber } from "ethers"; +import SpikeProtectionGasPriceProvider from "./providers/spike-protection-gas-price-provider"; export const gasPriceProviderTypes = [ "eth-provider", @@ -62,6 +64,7 @@ export const createGasPriceProvider = async ( provider, GAS_PRICE_MULTIPLIER ); + let proxiedGasPriceProvider: GasPriceProvider = providerGasPriceProvider; switch (type) { case "fastest": @@ -83,10 +86,11 @@ export const createGasPriceProvider = async ( timeout: GAS_STATION_API_REQUEST_TIMEOUT_MS, profile: type, }); - return new ChainGasPriceProvider([ + proxiedGasPriceProvider = new ChainGasPriceProvider([ gasStationProvider, providerGasPriceProvider, ]); + break; case "eip-1559-urgent": case "eip-1559-fast": case "eip-1559-normal": @@ -110,9 +114,17 @@ export const createGasPriceProvider = async ( profile = "normal"; break; } - return new Eip1559GasPriceProvider(provider, profile); + proxiedGasPriceProvider = new Eip1559GasPriceProvider( + provider, + profile + ); + break; + default: + log.debug(`using gas price predictor from ethereum provider`); } - log.debug(`using gas price predictor from ethereum provider`); - return providerGasPriceProvider; + return new SpikeProtectionGasPriceProvider( + proxiedGasPriceProvider, + MAX_GAS_PRICE_GWEI + ); }; diff --git a/src/gas-price/providers/spike-protection-gas-price-provider.ts b/src/gas-price/providers/spike-protection-gas-price-provider.ts new file mode 100644 index 0000000..573abad --- /dev/null +++ b/src/gas-price/providers/spike-protection-gas-price-provider.ts @@ -0,0 +1,47 @@ +// Copyright 2020 Cartesi Pte. Ltd. + +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use +// this file except in compliance with the License. You may obtain a copy of the +// License at http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software distributed +// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +// CONDITIONS OF ANY KIND, either express or implied. See the License for the +// specific language governing permissions and limitations under the License. + +import { GasPriceOverrides, GasPriceProvider } from "../gas-price-provider"; +import { BigNumber, utils } from "ethers"; + +export default class SpikeProtectionGasPriceProvider + implements GasPriceProvider +{ + private readonly proxiedGasPriceProviders: GasPriceProvider; + private readonly maxGasPrice: BigNumber; + + constructor( + proxiedGasPriceProviders: GasPriceProvider, + maxGasPriceGwei: number + ) { + this.proxiedGasPriceProviders = proxiedGasPriceProviders; + this.maxGasPrice = utils.parseUnits(maxGasPriceGwei.toString(), "gwei"); + } + + getGasPrice = async (): Promise => { + const overrides = await this.proxiedGasPriceProviders.getGasPrice(); + let gasPrice: BigNumber; + if ("maxFeePerGas" in overrides) { + gasPrice = overrides.maxFeePerGas; + } else { + gasPrice = overrides.gasPrice; + } + if (gasPrice.lt(this.maxGasPrice)) { + return overrides; + } + throw new Error( + `Gas price higher than ${utils.formatUnits( + this.maxGasPrice, + "gwei" + )}` + ); + }; +} diff --git a/test/gas-price/providers/chain-gas-price-provider.spec.ts b/test/gas-price/providers/chain-gas-price-provider.spec.ts index ae88772..bddef04 100644 --- a/test/gas-price/providers/chain-gas-price-provider.spec.ts +++ b/test/gas-price/providers/chain-gas-price-provider.spec.ts @@ -64,7 +64,7 @@ describe("chain gas price provider test suite", () => { it("should not provide gas price with no providers", async () => { sandbox.stub(log, "error").returns(); const gasPriceProvider = new ChainGasPriceProvider([]); - expect(gasPriceProvider.getGasPrice()).to.be.rejectedWith( + await expect(gasPriceProvider.getGasPrice()).to.be.rejectedWith( "no valid gas price returned from the chain of gas price providers" ); }); @@ -77,7 +77,7 @@ describe("chain gas price provider test suite", () => { gasPriceProvider1, gasPriceProvider2, ]); - expect(gasPriceProvider.getGasPrice()).to.be.rejectedWith( + await expect(gasPriceProvider.getGasPrice()).to.be.rejectedWith( "no valid gas price returned from the chain of gas price providers" ); }); diff --git a/test/gas-price/providers/gas-station-gas-price-provider.spec.ts b/test/gas-price/providers/gas-station-gas-price-provider.spec.ts index 867c712..3f89792 100644 --- a/test/gas-price/providers/gas-station-gas-price-provider.spec.ts +++ b/test/gas-price/providers/gas-station-gas-price-provider.spec.ts @@ -44,7 +44,7 @@ describe("gas station gas price provider test suite", () => { sandbox.stub(log, "error").returns(); sandbox.stub(axios, "get").returns(Promise.reject("test error")); const provider = new GasStationGasPriceProvider(); - expect(provider.getGasPrice).to.throw; + await expect(provider.getGasPrice()).to.be.rejected; }); it("should reject on missing price", async () => { @@ -52,7 +52,7 @@ describe("gas station gas price provider test suite", () => { sandbox.stub(log, "error").returns(); sandbox.stub(axios, "get").returns(Promise.resolve({ data: {} })); const gasPriceProvider = new GasStationGasPriceProvider({ profile }); - expect(gasPriceProvider.getGasPrice()).to.eventually.throw( + await expect(gasPriceProvider.getGasPrice()).to.be.rejectedWith( `gas station did not return a ${profile} price` ); }); diff --git a/test/gas-price/providers/spike-protection-gas-price-provider.spec.ts b/test/gas-price/providers/spike-protection-gas-price-provider.spec.ts new file mode 100644 index 0000000..a779255 --- /dev/null +++ b/test/gas-price/providers/spike-protection-gas-price-provider.spec.ts @@ -0,0 +1,91 @@ +// Copyright 2021 Cartesi Pte. Ltd. + +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use +// this file except in compliance with the License. You may obtain a copy of the +// License at http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software distributed +// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +// CONDITIONS OF ANY KIND, either express or implied. See the License for the +// specific language governing permissions and limitations under the License. + +import chain, { expect } from "chai"; +import chaiAsPromised from "chai-as-promised"; +import sinon from "sinon"; +import { + GasPriceOverride, + GasPriceOverrideEip1559, + GasPriceOverrides, + GasPriceProvider, +} from "../../../src/gas-price/gas-price-provider"; +import { BigNumber, utils } from "ethers"; +import ChainGasPriceProvider from "../../../src/gas-price/providers/chain-gas-price-provider"; +import log from "loglevel"; +import SpikeProtectionGasPriceProvider from "../../../src/gas-price/providers/spike-protection-gas-price-provider"; + +chain.use(chaiAsPromised); +const sandbox = sinon.createSandbox(); + +class gasProviderMock implements GasPriceProvider { + private readonly gasPrice: BigNumber; + private readonly isEip1559: boolean; + + constructor(gasPrice: BigNumber, isEip1559 = false) { + this.gasPrice = gasPrice; + this.isEip1559 = isEip1559; + } + + async getGasPrice(): Promise { + if (this.isEip1559) { + const maxPriorityFeePerGas = utils.parseUnits("2", "gwei"); + const maxFeePerGas = maxPriorityFeePerGas.add(this.gasPrice); + return { + maxFeePerGas: maxFeePerGas, + maxPriorityFeePerGas: maxPriorityFeePerGas, + }; + } + return { gasPrice: this.gasPrice }; + } +} + +describe("spike protection gas price provider test suite", () => { + afterEach(() => { + sandbox.restore(); + }); + + it("should provide gas price when under threshold", async () => { + sandbox.stub(log, "error").returns(); + const currentGasPrice = utils.parseUnits("50", "gwei"); + const gasPriceProvider1 = new SpikeProtectionGasPriceProvider( + new gasProviderMock(currentGasPrice, false), + 100 + ); + const gasPriceProvider2 = new SpikeProtectionGasPriceProvider( + new gasProviderMock(currentGasPrice, true), + 100 + ); + const gasPrice1 = + (await gasPriceProvider1.getGasPrice()) as GasPriceOverride; + const gasPrice2 = + (await gasPriceProvider2.getGasPrice()) as GasPriceOverrideEip1559; + expect(gasPrice1.gasPrice).to.be.deep.eq(currentGasPrice); + expect(gasPrice2.maxFeePerGas).to.be.deep.eq( + currentGasPrice.add(gasPrice2.maxPriorityFeePerGas) + ); + }); + + it("should reject when over threshold", async () => { + sandbox.stub(log, "error").returns(); + const currentGasPrice = utils.parseUnits("50", "gwei"); + const gasPriceProvider1 = new SpikeProtectionGasPriceProvider( + new gasProviderMock(currentGasPrice, false), + 50 + ); + const gasPriceProvider2 = new SpikeProtectionGasPriceProvider( + new gasProviderMock(currentGasPrice, true), + 50 + ); + await expect(gasPriceProvider1.getGasPrice()).to.be.rejected; + await expect(gasPriceProvider2.getGasPrice()).to.be.rejected; + }); +});