Skip to content

Commit

Permalink
Initial implementation (#1)
Browse files Browse the repository at this point in the history
* Port most code to library

* Add deploy script

* Seperate from MyCrypto provider

* Add multi provider support

* Clean up types

* Actually export code

* Use eth-scan for multi provider support

* Remove eslint boolean rule

* Bump eth-scan

* Use native bigint

* Remove unnecesary comments

* Use generic parameter for reduce
  • Loading branch information
FrederikBolding authored Sep 17, 2021
1 parent 46d780f commit 11aeb3b
Show file tree
Hide file tree
Showing 16 changed files with 458 additions and 4 deletions.
3 changes: 1 addition & 2 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,7 @@
"ignoreRestSiblings": true,
"argsIgnorePattern": "^_"
}
],
"@typescript-eslint/strict-boolean-expressions": ["error", { "allowNullableBoolean": true }]
]
},
"parserOptions": {
"project": ["./tsconfig.json"],
Expand Down
55 changes: 55 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: Deploy

on:
push:
branches:
- 'master'

jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
strategy:
matrix:
node-version:
- 12

steps:
- uses: actions/checkout@v2

- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/[email protected]
with:
node-version: ${{ matrix.node-version }}

- name: Cache Dependencies
uses: actions/[email protected]
with:
path: node_modules
key: yarn-${{ hashFiles('yarn.lock') }}

- name: Install Dependencies
run: yarn install --frozen-lockfile --ignore-scripts

- uses: JS-DevTools/npm-publish@v1
id: publish
with:
token: ${{ secrets.NPM_TOKEN }}

- name: Push tag
if: steps.publish.outputs.type != 'none'
id: tag_version
uses: mathieudutour/[email protected]
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
custom_tag: ${{ steps.publish.outputs.version }}

- name: Create a GitHub release
if: steps.publish.outputs.type != 'none'
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ steps.tag_version.outputs.new_tag }}
release_name: Release ${{ steps.tag_version.outputs.new_tag }}
body: ${{ steps.tag_version.outputs.changelog }}
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
"prepare": "simple-git-hooks",
"prepack": "yarn build"
},
"dependencies": {},
"dependencies": {
"@mycrypto/eth-scan": "3.5.1"
},
"devDependencies": {
"@babel/cli": "^7.13.14",
"@babel/core": "^7.13.14",
Expand Down
163 changes: 163 additions & 0 deletions src/eip1559.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { estimateFees, FALLBACK_ESTIMATE } from './eip1559';

const block = {
hash: '0x38b34c2313e148a0916406a204536c03e5bf77312c558d25d3b63d8a4e30af47',
parentHash: '0xc33eb2f6795e58cb9ad800bfeed0463a14c8a94a9e621de14fd05a782f1ffbd4',
number: 5219914,
timestamp: 1627469703,
nonce: '0x0000000000000000',
difficulty: 1,
gasLimit: 0x01c9c380,
gasUsed: 0x26aee4,
miner: '0x0000000000000000000000000000000000000000',
extraData:
'0xd883010a05846765746888676f312e31362e35856c696e757800000000000000a866c8e4b72c133037132849cf9419f32126bf93dfb5a42b092828fd4bfa5e8e2ce59121cb2516740c03af11225e5b6a2d9dad29cf4fe77a70af26c4ce30236601',
transactions: [],
baseFeePerGas: '0x2540be400'
};

const feeHistory = {
oldestBlock: '0x4fa645',
reward: [['0x0'], ['0x3b9aca00'], ['0x12a05f1f9'], ['0x3b9aca00'], ['0x12a05f1f9']],
baseFeePerGas: ['0x7', '0x7', '0x7', '0x7', '0x7', '0x7'],
gasUsedRatio: [0, 0.10772606666666666, 0.0084, 0.12964573239101315, 0.06693689580776942]
};

describe('estimateFees', () => {
afterEach(() => {
jest.resetAllMocks();
});
const mockProvider = {
send: jest.fn()
};
it('estimates without using priority fees', () => {
mockProvider.send.mockResolvedValueOnce(block);
return expect(estimateFees(mockProvider)).resolves.toStrictEqual({
baseFee: 10000000000n,
maxFeePerGas: 20000000000n,
maxPriorityFeePerGas: 3000000000n
});
});

it('estimates priority fees', async () => {
mockProvider.send.mockImplementation((method) => {
if (method === 'eth_feeHistory') {
return feeHistory;
}
return { ...block, baseFeePerGas: '0x174876e800' };
});
return expect(estimateFees(mockProvider)).resolves.toStrictEqual({
baseFee: 100000000000n,
maxFeePerGas: 160000000000n,
maxPriorityFeePerGas: 5000000000n
});
});

it('estimates priority fees removing low outliers', async () => {
mockProvider.send.mockImplementation((method) => {
if (method === 'eth_feeHistory') {
return {
...feeHistory,
reward: [
['0x1'],
['0x1'],
['0x1'],
['0x1'],
['0x1'],
['0x1a13b8600'],
['0x12a05f1f9'],
['0x3b9aca00'],
['0x1a13b8600']
]
};
}
return { ...block, baseFeePerGas: '0x174876e800' };
});
return expect(estimateFees(mockProvider)).resolves.toStrictEqual({
baseFee: 100000000000n,
maxFeePerGas: 160000000000n,
maxPriorityFeePerGas: 5000000000n
});
});

it('uses 1.6 multiplier for base if above 40 gwei', async () => {
mockProvider.send.mockImplementation((method) => {
if (method === 'eth_feeHistory') {
return feeHistory;
}
return { ...block, baseFeePerGas: '0x11766ffa76' };
});
return expect(estimateFees(mockProvider)).resolves.toStrictEqual({
baseFee: 75001494134n,
maxFeePerGas: 120000000000n,
maxPriorityFeePerGas: 3000000000n
});
});

it('uses 1.4 multiplier for base if above 100 gwei', async () => {
mockProvider.send.mockImplementation((method) => {
if (method === 'eth_feeHistory') {
return feeHistory;
}
return { ...block, baseFeePerGas: '0x2e90edd000' };
});
return expect(estimateFees(mockProvider)).resolves.toStrictEqual({
baseFee: 200000000000n,
maxFeePerGas: 280000000000n,
maxPriorityFeePerGas: 5000000000n
});
});

it('uses 1.2 multiplier for base if above 200 gwei', async () => {
mockProvider.send.mockImplementation((method) => {
if (method === 'eth_feeHistory') {
return feeHistory;
}
return { ...block, baseFeePerGas: '0x45d964b800' };
});
return expect(estimateFees(mockProvider)).resolves.toStrictEqual({
baseFee: 300000000000n,
maxFeePerGas: 360000000000n,
maxPriorityFeePerGas: 5000000000n
});
});

it('handles baseFee being smaller than priorityFee', async () => {
mockProvider.send.mockImplementation((method) => {
if (method === 'eth_feeHistory') {
return feeHistory;
}
return { ...block, baseFeePerGas: '0x7' };
});
return expect(estimateFees(mockProvider)).resolves.toStrictEqual({
baseFee: 7n,
maxFeePerGas: 3000000000n,
maxPriorityFeePerGas: 3000000000n
});
});

it('falls back if no baseFeePerGas on block', async () => {
mockProvider.send.mockResolvedValueOnce({ ...block, baseFeePerGas: undefined });
return expect(estimateFees(mockProvider)).resolves.toStrictEqual(FALLBACK_ESTIMATE);
});

it('falls back if priority fetching fails', async () => {
mockProvider.send.mockImplementation((method) => {
if (method === 'eth_feeHistory') {
return { ...feeHistory, reward: undefined };
}
return { ...block, baseFeePerGas: '0x45d964b800' };
});
return expect(estimateFees(mockProvider)).resolves.toStrictEqual(FALLBACK_ESTIMATE);
});

it('falls back if gas is VERY high', async () => {
mockProvider.send.mockImplementation((method) => {
if (method === 'eth_feeHistory') {
return feeHistory;
}
return { ...block, baseFeePerGas: '0x91812d7d600' };
});
return expect(estimateFees(mockProvider)).resolves.toStrictEqual(FALLBACK_ESTIMATE);
});
});
127 changes: 127 additions & 0 deletions src/eip1559.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import type { ProviderLike } from '@mycrypto/eth-scan';

import { getFeeHistory, getLatestBlock } from './provider';
import type { EstimationResult } from './types';
import { gwei, hexlify, max, roundToWholeGwei } from './utils';

const MAX_GAS_FAST = gwei(1500n);

// How many blocks to consider for priority fee estimation
const FEE_HISTORY_BLOCKS = 10;
// Which percentile of effective priority fees to include
const FEE_HISTORY_PERCENTILE = 5;
// Which base fee to trigger priority fee estimation at
const PRIORITY_FEE_ESTIMATION_TRIGGER = gwei(100n);
// Returned if above trigger is not met
const DEFAULT_PRIORITY_FEE = gwei(3n);
// In case something goes wrong fall back to this estimate
export const FALLBACK_ESTIMATE = {
maxFeePerGas: gwei(20n),
maxPriorityFeePerGas: DEFAULT_PRIORITY_FEE,
baseFee: undefined
};
const PRIORITY_FEE_INCREASE_BOUNDARY = 200; // %

// Returns base fee multiplier percentage
const getBaseFeeMultiplier = (baseFee: bigint) => {
if (baseFee <= gwei(40n)) {
return 200n;
} else if (baseFee <= gwei(100n)) {
return 160n;
} else if (baseFee <= gwei(200n)) {
return 140n;
} else {
return 120n;
}
};

const estimatePriorityFee = async (
provider: ProviderLike,
baseFee: bigint,
blockNumber: bigint
) => {
if (baseFee < PRIORITY_FEE_ESTIMATION_TRIGGER) {
return DEFAULT_PRIORITY_FEE;
}
const feeHistory = await getFeeHistory(
provider,
hexlify(FEE_HISTORY_BLOCKS),
hexlify(blockNumber),
[FEE_HISTORY_PERCENTILE]
);

const rewards = feeHistory.reward
?.map((r) => BigInt(r[0]))
.filter((r) => r > 0n)
.sort();

if (!rewards) {
return null;
}

// Calculate percentage increases from between ordered list of fees
const percentageIncreases = rewards.reduce<bigint[]>((acc, cur, i, arr) => {
if (i === arr.length - 1) {
return acc;
}
const next = arr[i + 1];
const p = ((next - cur) / cur) * 100n;
return [...acc, p];
}, []);
const highestIncrease = max(percentageIncreases);
const highestIncreaseIndex = percentageIncreases.findIndex((p) => p === highestIncrease);

// If we have big increase in value, we could be considering "outliers" in our estimate
// Skip the low elements and take a new median
const values =
highestIncrease >= PRIORITY_FEE_INCREASE_BOUNDARY &&
highestIncreaseIndex >= Math.floor(rewards.length / 2)
? rewards.slice(highestIncreaseIndex)
: rewards;

return values[Math.floor(values.length / 2)];
};

export const estimateFees = async (provider: ProviderLike): Promise<EstimationResult> => {
try {
const latestBlock = await getLatestBlock(provider);

if (!latestBlock.baseFeePerGas) {
throw new Error('An error occurred while fetching current base fee, falling back');
}

const baseFee = BigInt(latestBlock.baseFeePerGas);

const blockNumber = BigInt(latestBlock.number);

const estimatedPriorityFee = await estimatePriorityFee(provider, baseFee, blockNumber);

if (estimatedPriorityFee === null) {
throw new Error('An error occurred while estimating priority fee, falling back');
}

const maxPriorityFeePerGas = max([estimatedPriorityFee, DEFAULT_PRIORITY_FEE]);

const multiplier = getBaseFeeMultiplier(baseFee);

const potentialMaxFee = (baseFee * multiplier) / 100n;
const maxFeePerGas =
maxPriorityFeePerGas > potentialMaxFee
? potentialMaxFee + maxPriorityFeePerGas
: potentialMaxFee;

if (maxFeePerGas >= MAX_GAS_FAST || maxPriorityFeePerGas >= MAX_GAS_FAST) {
throw new Error('Estimated gas fee was much higher than expected, erroring');
}

return {
maxFeePerGas: roundToWholeGwei(maxFeePerGas),
maxPriorityFeePerGas: roundToWholeGwei(maxPriorityFeePerGas),
baseFee
};
} catch (err) {
// eslint-disable-next-line no-console
console.error(err);
return FALLBACK_ESTIMATE;
}
};
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
// @todo
export * from './eip1559';
export * from './types';
18 changes: 18 additions & 0 deletions src/provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { ProviderLike } from '@mycrypto/eth-scan';
import { send } from '@mycrypto/eth-scan';

import type { Block, FeeHistory } from './types';

export const getBlock = (provider: ProviderLike, blockNumber: string | number): Promise<Block> =>
send<Block>(provider, 'eth_getBlockByNumber', [blockNumber, false]);

export const getLatestBlock = (provider: ProviderLike): Promise<Block> =>
getBlock(provider, 'latest');

export const getFeeHistory = (
provider: ProviderLike,
blockCount: string,
newestBlock: string,
rewardPercentiles?: number[]
): Promise<FeeHistory> =>
send<FeeHistory>(provider, 'eth_feeHistory', [blockCount, newestBlock, rewardPercentiles ?? []]);
Loading

0 comments on commit 11aeb3b

Please sign in to comment.