Skip to content

Commit 11aeb3b

Browse files
Initial implementation (#1)
* 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
1 parent 46d780f commit 11aeb3b

File tree

16 files changed

+458
-4
lines changed

16 files changed

+458
-4
lines changed

.eslintrc.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,7 @@
7474
"ignoreRestSiblings": true,
7575
"argsIgnorePattern": "^_"
7676
}
77-
],
78-
"@typescript-eslint/strict-boolean-expressions": ["error", { "allowNullableBoolean": true }]
77+
]
7978
},
8079
"parserOptions": {
8180
"project": ["./tsconfig.json"],

.github/workflows/deploy.yml

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
name: Deploy
2+
3+
on:
4+
push:
5+
branches:
6+
- 'master'
7+
8+
jobs:
9+
deploy:
10+
name: Deploy
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
node-version:
15+
- 12
16+
17+
steps:
18+
- uses: actions/checkout@v2
19+
20+
- name: Setup Node.js ${{ matrix.node-version }}
21+
uses: actions/[email protected]
22+
with:
23+
node-version: ${{ matrix.node-version }}
24+
25+
- name: Cache Dependencies
26+
uses: actions/[email protected]
27+
with:
28+
path: node_modules
29+
key: yarn-${{ hashFiles('yarn.lock') }}
30+
31+
- name: Install Dependencies
32+
run: yarn install --frozen-lockfile --ignore-scripts
33+
34+
- uses: JS-DevTools/npm-publish@v1
35+
id: publish
36+
with:
37+
token: ${{ secrets.NPM_TOKEN }}
38+
39+
- name: Push tag
40+
if: steps.publish.outputs.type != 'none'
41+
id: tag_version
42+
uses: mathieudutour/[email protected]
43+
with:
44+
github_token: ${{ secrets.GITHUB_TOKEN }}
45+
custom_tag: ${{ steps.publish.outputs.version }}
46+
47+
- name: Create a GitHub release
48+
if: steps.publish.outputs.type != 'none'
49+
uses: actions/create-release@v1
50+
env:
51+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
52+
with:
53+
tag_name: ${{ steps.tag_version.outputs.new_tag }}
54+
release_name: Release ${{ steps.tag_version.outputs.new_tag }}
55+
body: ${{ steps.tag_version.outputs.changelog }}

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@
2929
"prepare": "simple-git-hooks",
3030
"prepack": "yarn build"
3131
},
32-
"dependencies": {},
32+
"dependencies": {
33+
"@mycrypto/eth-scan": "3.5.1"
34+
},
3335
"devDependencies": {
3436
"@babel/cli": "^7.13.14",
3537
"@babel/core": "^7.13.14",

src/eip1559.test.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { estimateFees, FALLBACK_ESTIMATE } from './eip1559';
2+
3+
const block = {
4+
hash: '0x38b34c2313e148a0916406a204536c03e5bf77312c558d25d3b63d8a4e30af47',
5+
parentHash: '0xc33eb2f6795e58cb9ad800bfeed0463a14c8a94a9e621de14fd05a782f1ffbd4',
6+
number: 5219914,
7+
timestamp: 1627469703,
8+
nonce: '0x0000000000000000',
9+
difficulty: 1,
10+
gasLimit: 0x01c9c380,
11+
gasUsed: 0x26aee4,
12+
miner: '0x0000000000000000000000000000000000000000',
13+
extraData:
14+
'0xd883010a05846765746888676f312e31362e35856c696e757800000000000000a866c8e4b72c133037132849cf9419f32126bf93dfb5a42b092828fd4bfa5e8e2ce59121cb2516740c03af11225e5b6a2d9dad29cf4fe77a70af26c4ce30236601',
15+
transactions: [],
16+
baseFeePerGas: '0x2540be400'
17+
};
18+
19+
const feeHistory = {
20+
oldestBlock: '0x4fa645',
21+
reward: [['0x0'], ['0x3b9aca00'], ['0x12a05f1f9'], ['0x3b9aca00'], ['0x12a05f1f9']],
22+
baseFeePerGas: ['0x7', '0x7', '0x7', '0x7', '0x7', '0x7'],
23+
gasUsedRatio: [0, 0.10772606666666666, 0.0084, 0.12964573239101315, 0.06693689580776942]
24+
};
25+
26+
describe('estimateFees', () => {
27+
afterEach(() => {
28+
jest.resetAllMocks();
29+
});
30+
const mockProvider = {
31+
send: jest.fn()
32+
};
33+
it('estimates without using priority fees', () => {
34+
mockProvider.send.mockResolvedValueOnce(block);
35+
return expect(estimateFees(mockProvider)).resolves.toStrictEqual({
36+
baseFee: 10000000000n,
37+
maxFeePerGas: 20000000000n,
38+
maxPriorityFeePerGas: 3000000000n
39+
});
40+
});
41+
42+
it('estimates priority fees', async () => {
43+
mockProvider.send.mockImplementation((method) => {
44+
if (method === 'eth_feeHistory') {
45+
return feeHistory;
46+
}
47+
return { ...block, baseFeePerGas: '0x174876e800' };
48+
});
49+
return expect(estimateFees(mockProvider)).resolves.toStrictEqual({
50+
baseFee: 100000000000n,
51+
maxFeePerGas: 160000000000n,
52+
maxPriorityFeePerGas: 5000000000n
53+
});
54+
});
55+
56+
it('estimates priority fees removing low outliers', async () => {
57+
mockProvider.send.mockImplementation((method) => {
58+
if (method === 'eth_feeHistory') {
59+
return {
60+
...feeHistory,
61+
reward: [
62+
['0x1'],
63+
['0x1'],
64+
['0x1'],
65+
['0x1'],
66+
['0x1'],
67+
['0x1a13b8600'],
68+
['0x12a05f1f9'],
69+
['0x3b9aca00'],
70+
['0x1a13b8600']
71+
]
72+
};
73+
}
74+
return { ...block, baseFeePerGas: '0x174876e800' };
75+
});
76+
return expect(estimateFees(mockProvider)).resolves.toStrictEqual({
77+
baseFee: 100000000000n,
78+
maxFeePerGas: 160000000000n,
79+
maxPriorityFeePerGas: 5000000000n
80+
});
81+
});
82+
83+
it('uses 1.6 multiplier for base if above 40 gwei', async () => {
84+
mockProvider.send.mockImplementation((method) => {
85+
if (method === 'eth_feeHistory') {
86+
return feeHistory;
87+
}
88+
return { ...block, baseFeePerGas: '0x11766ffa76' };
89+
});
90+
return expect(estimateFees(mockProvider)).resolves.toStrictEqual({
91+
baseFee: 75001494134n,
92+
maxFeePerGas: 120000000000n,
93+
maxPriorityFeePerGas: 3000000000n
94+
});
95+
});
96+
97+
it('uses 1.4 multiplier for base if above 100 gwei', async () => {
98+
mockProvider.send.mockImplementation((method) => {
99+
if (method === 'eth_feeHistory') {
100+
return feeHistory;
101+
}
102+
return { ...block, baseFeePerGas: '0x2e90edd000' };
103+
});
104+
return expect(estimateFees(mockProvider)).resolves.toStrictEqual({
105+
baseFee: 200000000000n,
106+
maxFeePerGas: 280000000000n,
107+
maxPriorityFeePerGas: 5000000000n
108+
});
109+
});
110+
111+
it('uses 1.2 multiplier for base if above 200 gwei', async () => {
112+
mockProvider.send.mockImplementation((method) => {
113+
if (method === 'eth_feeHistory') {
114+
return feeHistory;
115+
}
116+
return { ...block, baseFeePerGas: '0x45d964b800' };
117+
});
118+
return expect(estimateFees(mockProvider)).resolves.toStrictEqual({
119+
baseFee: 300000000000n,
120+
maxFeePerGas: 360000000000n,
121+
maxPriorityFeePerGas: 5000000000n
122+
});
123+
});
124+
125+
it('handles baseFee being smaller than priorityFee', async () => {
126+
mockProvider.send.mockImplementation((method) => {
127+
if (method === 'eth_feeHistory') {
128+
return feeHistory;
129+
}
130+
return { ...block, baseFeePerGas: '0x7' };
131+
});
132+
return expect(estimateFees(mockProvider)).resolves.toStrictEqual({
133+
baseFee: 7n,
134+
maxFeePerGas: 3000000000n,
135+
maxPriorityFeePerGas: 3000000000n
136+
});
137+
});
138+
139+
it('falls back if no baseFeePerGas on block', async () => {
140+
mockProvider.send.mockResolvedValueOnce({ ...block, baseFeePerGas: undefined });
141+
return expect(estimateFees(mockProvider)).resolves.toStrictEqual(FALLBACK_ESTIMATE);
142+
});
143+
144+
it('falls back if priority fetching fails', async () => {
145+
mockProvider.send.mockImplementation((method) => {
146+
if (method === 'eth_feeHistory') {
147+
return { ...feeHistory, reward: undefined };
148+
}
149+
return { ...block, baseFeePerGas: '0x45d964b800' };
150+
});
151+
return expect(estimateFees(mockProvider)).resolves.toStrictEqual(FALLBACK_ESTIMATE);
152+
});
153+
154+
it('falls back if gas is VERY high', async () => {
155+
mockProvider.send.mockImplementation((method) => {
156+
if (method === 'eth_feeHistory') {
157+
return feeHistory;
158+
}
159+
return { ...block, baseFeePerGas: '0x91812d7d600' };
160+
});
161+
return expect(estimateFees(mockProvider)).resolves.toStrictEqual(FALLBACK_ESTIMATE);
162+
});
163+
});

src/eip1559.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import type { ProviderLike } from '@mycrypto/eth-scan';
2+
3+
import { getFeeHistory, getLatestBlock } from './provider';
4+
import type { EstimationResult } from './types';
5+
import { gwei, hexlify, max, roundToWholeGwei } from './utils';
6+
7+
const MAX_GAS_FAST = gwei(1500n);
8+
9+
// How many blocks to consider for priority fee estimation
10+
const FEE_HISTORY_BLOCKS = 10;
11+
// Which percentile of effective priority fees to include
12+
const FEE_HISTORY_PERCENTILE = 5;
13+
// Which base fee to trigger priority fee estimation at
14+
const PRIORITY_FEE_ESTIMATION_TRIGGER = gwei(100n);
15+
// Returned if above trigger is not met
16+
const DEFAULT_PRIORITY_FEE = gwei(3n);
17+
// In case something goes wrong fall back to this estimate
18+
export const FALLBACK_ESTIMATE = {
19+
maxFeePerGas: gwei(20n),
20+
maxPriorityFeePerGas: DEFAULT_PRIORITY_FEE,
21+
baseFee: undefined
22+
};
23+
const PRIORITY_FEE_INCREASE_BOUNDARY = 200; // %
24+
25+
// Returns base fee multiplier percentage
26+
const getBaseFeeMultiplier = (baseFee: bigint) => {
27+
if (baseFee <= gwei(40n)) {
28+
return 200n;
29+
} else if (baseFee <= gwei(100n)) {
30+
return 160n;
31+
} else if (baseFee <= gwei(200n)) {
32+
return 140n;
33+
} else {
34+
return 120n;
35+
}
36+
};
37+
38+
const estimatePriorityFee = async (
39+
provider: ProviderLike,
40+
baseFee: bigint,
41+
blockNumber: bigint
42+
) => {
43+
if (baseFee < PRIORITY_FEE_ESTIMATION_TRIGGER) {
44+
return DEFAULT_PRIORITY_FEE;
45+
}
46+
const feeHistory = await getFeeHistory(
47+
provider,
48+
hexlify(FEE_HISTORY_BLOCKS),
49+
hexlify(blockNumber),
50+
[FEE_HISTORY_PERCENTILE]
51+
);
52+
53+
const rewards = feeHistory.reward
54+
?.map((r) => BigInt(r[0]))
55+
.filter((r) => r > 0n)
56+
.sort();
57+
58+
if (!rewards) {
59+
return null;
60+
}
61+
62+
// Calculate percentage increases from between ordered list of fees
63+
const percentageIncreases = rewards.reduce<bigint[]>((acc, cur, i, arr) => {
64+
if (i === arr.length - 1) {
65+
return acc;
66+
}
67+
const next = arr[i + 1];
68+
const p = ((next - cur) / cur) * 100n;
69+
return [...acc, p];
70+
}, []);
71+
const highestIncrease = max(percentageIncreases);
72+
const highestIncreaseIndex = percentageIncreases.findIndex((p) => p === highestIncrease);
73+
74+
// If we have big increase in value, we could be considering "outliers" in our estimate
75+
// Skip the low elements and take a new median
76+
const values =
77+
highestIncrease >= PRIORITY_FEE_INCREASE_BOUNDARY &&
78+
highestIncreaseIndex >= Math.floor(rewards.length / 2)
79+
? rewards.slice(highestIncreaseIndex)
80+
: rewards;
81+
82+
return values[Math.floor(values.length / 2)];
83+
};
84+
85+
export const estimateFees = async (provider: ProviderLike): Promise<EstimationResult> => {
86+
try {
87+
const latestBlock = await getLatestBlock(provider);
88+
89+
if (!latestBlock.baseFeePerGas) {
90+
throw new Error('An error occurred while fetching current base fee, falling back');
91+
}
92+
93+
const baseFee = BigInt(latestBlock.baseFeePerGas);
94+
95+
const blockNumber = BigInt(latestBlock.number);
96+
97+
const estimatedPriorityFee = await estimatePriorityFee(provider, baseFee, blockNumber);
98+
99+
if (estimatedPriorityFee === null) {
100+
throw new Error('An error occurred while estimating priority fee, falling back');
101+
}
102+
103+
const maxPriorityFeePerGas = max([estimatedPriorityFee, DEFAULT_PRIORITY_FEE]);
104+
105+
const multiplier = getBaseFeeMultiplier(baseFee);
106+
107+
const potentialMaxFee = (baseFee * multiplier) / 100n;
108+
const maxFeePerGas =
109+
maxPriorityFeePerGas > potentialMaxFee
110+
? potentialMaxFee + maxPriorityFeePerGas
111+
: potentialMaxFee;
112+
113+
if (maxFeePerGas >= MAX_GAS_FAST || maxPriorityFeePerGas >= MAX_GAS_FAST) {
114+
throw new Error('Estimated gas fee was much higher than expected, erroring');
115+
}
116+
117+
return {
118+
maxFeePerGas: roundToWholeGwei(maxFeePerGas),
119+
maxPriorityFeePerGas: roundToWholeGwei(maxPriorityFeePerGas),
120+
baseFee
121+
};
122+
} catch (err) {
123+
// eslint-disable-next-line no-console
124+
console.error(err);
125+
return FALLBACK_ESTIMATE;
126+
}
127+
};

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
// @todo
1+
export * from './eip1559';
2+
export * from './types';

src/provider.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { ProviderLike } from '@mycrypto/eth-scan';
2+
import { send } from '@mycrypto/eth-scan';
3+
4+
import type { Block, FeeHistory } from './types';
5+
6+
export const getBlock = (provider: ProviderLike, blockNumber: string | number): Promise<Block> =>
7+
send<Block>(provider, 'eth_getBlockByNumber', [blockNumber, false]);
8+
9+
export const getLatestBlock = (provider: ProviderLike): Promise<Block> =>
10+
getBlock(provider, 'latest');
11+
12+
export const getFeeHistory = (
13+
provider: ProviderLike,
14+
blockCount: string,
15+
newestBlock: string,
16+
rewardPercentiles?: number[]
17+
): Promise<FeeHistory> =>
18+
send<FeeHistory>(provider, 'eth_feeHistory', [blockCount, newestBlock, rewardPercentiles ?? []]);

0 commit comments

Comments
 (0)