diff --git a/package-lock.json b/package-lock.json index f4db455..bcab0f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,12 @@ "integrity": "sha512-ZvO2tAcjmMi8V/5Z3JsyofMe3hasRcaw88cto5etSVMwVQfeivGAlEYmaQgceUSVYFofVjT+ioHsATjdWcFt1w==", "dev": true }, + "@types/mockery": { + "version": "1.4.29", + "resolved": "https://registry.npmjs.org/@types/mockery/-/mockery-1.4.29.tgz", + "integrity": "sha1-m6It838H43gP/4Ux0aOOYz+UV6U=", + "dev": true + }, "@types/node": { "version": "14.0.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.0.5.tgz", @@ -118,6 +124,11 @@ "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", "dev": true }, + "bignumber.js": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.0.tgz", + "integrity": "sha512-t/OYhhJ2SD+YGBQcjY8GzzDHEk9f3nerxjtfa6tlMXfe7frs/WozhvCNoGvpM0P3bNf3Gq5ZRMlGr5f3r4/N8A==" + }, "binary-extensions": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.0.0.tgz", @@ -180,6 +191,15 @@ "type-detect": "^4.0.5" } }, + "chai-as-promised": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.1.tgz", + "integrity": "sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==", + "dev": true, + "requires": { + "check-error": "^1.0.2" + } + }, "chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -774,6 +794,12 @@ } } }, + "mockery": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mockery/-/mockery-2.1.0.tgz", + "integrity": "sha512-9VkOmxKlWXoDO/h1jDZaS4lH33aWfRiJiNT/tKj+8OGzrcFDLo8d0syGdbsc3Bc4GvRXPb+NMMvojotmuGJTvA==", + "dev": true + }, "ms": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", diff --git a/package.json b/package.json index 8aaf37f..976b18c 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "prepare": "npm run build", "prepublishOnly": "npm run lint", "scripts": { - "test": "mocha -r ts-node/register tests/*.test.ts", + "test": "mocha --timeout 30000 -r ts-node/register tests/*.test.ts", "build": "tsc", "lint": "tslint -p tsconfig.json" }, @@ -16,14 +16,18 @@ "devDependencies": { "@types/chai": "^4.2.11", "@types/mocha": "^7.0.2", + "@types/mockery": "^1.4.29", "chai": "^4.2.0", + "chai-as-promised": "^7.1.1", "mocha": "^7.2.0", + "mockery": "^2.1.0", "tslint": "^6.1.2", "tslint-config-standard": "^9.0.0" }, "dependencies": { "@types/node": "^14.0.5", "@types/node-fetch": "^2.5.7", + "bignumber.js": "^9.0.0", "node-fetch": "^2.6.0", "ts-node": "^8.10.1", "typescript": "^3.9.3" diff --git a/src/config.ts b/src/config.ts index 6863c63..b42a707 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,7 +1,7 @@ -import { Oracle } from './types'; +import { OffChainOracle, OnChainOracle } from './types'; -const ethgasstation: Oracle = { +const ethgasstation: OffChainOracle = { name: 'ethgasstation', url: 'https://ethgasstation.info/json/ethgasAPI.json', instantPropertyName: 'fastest', @@ -11,7 +11,7 @@ const ethgasstation: Oracle = { denominator: 10 }; -const zoltu: Oracle = { +const zoltu: OffChainOracle = { name: 'zoltu', url: 'https://gas-oracle.zoltu.io/', instantPropertyName: 'percentile_99', @@ -21,8 +21,18 @@ const zoltu: Oracle = { denominator: 1 }; +const chainLink: OnChainOracle = { + name: 'chainLink', + callData: '0x50d25bcd', + contract: '0xA417221ef64b1549575C977764E651c9FAB50141', + denominator: '1000000000' +}; + export default { - oracles: [ + offChainOracles: [ ethgasstation, zoltu + ], + onChainOracles: [ + chainLink ] }; diff --git a/src/index.ts b/src/index.ts index 3eb7d24..1aed21f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import fetch from 'node-fetch'; import config from './config'; -import { GasPrice, Oracle } from './types'; +import { GasPrice, OffChainOracle, OnChainOracle } from './types'; +import BigNumber from 'bignumber.js'; export class GasPriceOracle { lastGasPrice: GasPrice = { @@ -9,9 +10,16 @@ export class GasPriceOracle { standard: 10, low: 1 }; + defaultRpc = 'https://api.mycryptoapi.com/eth'; - async fetchGasPrices(): Promise { - for (let oracle of config.oracles) { + constructor(defaultRpc?: string) { + if (defaultRpc) { + this.defaultRpc = defaultRpc; + } + } + + async fetchGasPricesOffChain(throwIfFailsToFetch = true): Promise { + for (let oracle of config.offChainOracles) { const { name, url, instantPropertyName, fastPropertyName, standardPropertyName, lowPropertyName, denominator } = oracle; try { const response = await fetch(url); @@ -27,6 +35,7 @@ export class GasPriceOracle { low: parseFloat(gas[lowPropertyName]) / denominator }; this.lastGasPrice = gasPrices; + return this.lastGasPrice; } else { throw new Error(`Fetch gasPrice from ${name} oracle failed. Trying another one...`); } @@ -34,11 +43,63 @@ export class GasPriceOracle { console.error(e.message); } } - // TODO: additional arg `throwIfFailsToFetch` that throws if it fails to fetch from all oracles + if (throwIfFailsToFetch) { + throw new Error('All oracles are down. Probaly network error.'); + } return this.lastGasPrice; } - addOracle(oracle: Oracle) { - config.oracles.push(oracle); + async fetchGasPricesOnChain(throwIfFailsToFetch = true): Promise { + for (let oracle of config.onChainOracles) { + const { name, callData, contract, denominator } = oracle; + let { rpc } = oracle; + rpc = rpc ? rpc : this.defaultRpc; + const body = { jsonrpc: '2.0', + id: 1337, + method: 'eth_call', + params: [{ 'data': callData, 'to': contract }, 'latest'] + }; + try { + const response = await fetch(rpc, { + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify(body), + method: 'POST' + }); + if (response.status === 200) { + const { result } = await response.json(); + let fastGasPrice = new BigNumber(result); + if (fastGasPrice.isZero()) { + throw new Error(`${name} oracle provides corrupted values`); + } + fastGasPrice = fastGasPrice.div(denominator); + const gasPrices: GasPrice = { + instant: fastGasPrice.multipliedBy(1.3).toNumber(), + fast: fastGasPrice.toNumber(), + standard: fastGasPrice.multipliedBy(0.85).toNumber(), + low: fastGasPrice.multipliedBy(0.5).toNumber() + }; + this.lastGasPrice = gasPrices; + return this.lastGasPrice; + } else { + throw new Error(`Fetch gasPrice from ${name} oracle failed. Trying another one...`); + } + } catch (e) { + console.error(e.message); + } + } + if (throwIfFailsToFetch) { + throw new Error('All oracles are down. Probaly network error.'); + } + return this.lastGasPrice; + } + + addOffChainOracle(oracle: OffChainOracle) { + config.offChainOracles.push(oracle); + } + + addOnChainOracle(oracle: OnChainOracle) { + config.onChainOracles.push(oracle); } } diff --git a/src/types.ts b/src/types.ts index fe15fbb..d71b041 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,11 +1,19 @@ -export type Oracle = { - name: string - url: string - instantPropertyName: string, - fastPropertyName: string, - standardPropertyName: string, - lowPropertyName: string, - denominator: number +export type OffChainOracle = { + name: string; + url: string; + instantPropertyName: string; + fastPropertyName: string; + standardPropertyName: string; + lowPropertyName: string; + denominator: number; +}; + +export type OnChainOracle = { + name: string; + rpc?: string; + contract: string; + callData: string; + denominator: string; }; export type GasPrice = { diff --git a/tests/index.test.ts b/tests/index.test.ts index 614635f..18bb3a2 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,10 +1,91 @@ import { GasPriceOracle } from '../src/index'; import { GasPrice } from '../src/types'; +import mockery from 'mockery'; +import chai from 'chai'; -describe('Get gas price', function () { +chai.use(require('chai-as-promised')); +chai.should(); + +describe('fetchGasPricesOffChain', function () { it('should work', async function () { const oracle = new GasPriceOracle(); - const gas: GasPrice = await oracle.fetchGasPrices(); - console.log(gas); + const gas: GasPrice = await oracle.fetchGasPricesOffChain(); + + gas.instant.should.be.a('number'); + gas.fast.should.be.a('number'); + gas.standard.should.be.a('number'); + gas.low.should.be.a('number'); + + gas.instant.should.be.above(gas.fast); + gas.fast.should.be.above(gas.standard); + gas.standard.should.be.above(gas.low); + gas.low.should.not.be.equal(0); + }); +}); + +describe('throw checks', function () { + before('before', function () { + // Mocking the mod1 module + let fetchMock = () => { + throw new Error('Mocked for tests'); + }; + + // replace the module with mock for any `require` + mockery.registerMock('node-fetch', fetchMock); + + // set additional parameters + mockery.enable({ + useCleanCache: true, + // warnOnReplace: false, + warnOnUnregistered: false + }); + }); + + it('should throw if all offchain oracles are down', async function () { + const { GasPriceOracle } = require('../src/index'); + const oracle = new GasPriceOracle(); + await oracle.fetchGasPricesOffChain().should.be.rejectedWith('All oracles are down. Probaly network error.'); + }); + + it('should throw if all onchain oracles are down', async function () { + const { GasPriceOracle } = require('../src/index'); + const oracle = new GasPriceOracle(); + await oracle.fetchGasPricesOnChain().should.be.rejectedWith('All oracles are down. Probaly network error.'); + }); + + it('should not throw if throwIfFailsToFetch is false', async function () { + const { GasPriceOracle } = require('../src/index'); + const oracle = new GasPriceOracle(); + await oracle.fetchGasPricesOnChain(false); + }); + + it('should not throw if throwIfFailsToFetch is false', async function () { + const { GasPriceOracle } = require('../src/index'); + const oracle = new GasPriceOracle(); + await oracle.fetchGasPricesOffChain(false); + }); + + after('after', function () { + after(function () { + mockery.disable(); + mockery.deregisterMock('node-fetch'); + }); + }); +}); + +describe('fetchGasPricesOnChain', function () { + it('should work', async function () { + const oracle = new GasPriceOracle(); + const gas: GasPrice = await oracle.fetchGasPricesOnChain(); + + gas.instant.should.be.a('number'); + gas.fast.should.be.a('number'); + gas.standard.should.be.a('number'); + gas.low.should.be.a('number'); + + gas.instant.should.be.above(gas.fast); + gas.fast.should.be.above(gas.standard); + gas.standard.should.be.above(gas.low); + gas.low.should.not.be.equal(0); }); }); diff --git a/tslint.json b/tslint.json index 1fd14e6..98af075 100644 --- a/tslint.json +++ b/tslint.json @@ -3,7 +3,8 @@ "defaultSeverity": "error", "rules": { "semicolon": [true, "always"], - "space-before-function-paren": [true, {"anonymous": "always", "named": "never", "asyncArrow": "always"}] + "space-before-function-paren": [true, {"anonymous": "always", "named": "never", "asyncArrow": "always"}], + "type-literal-delimiter": true } }