3 Commits

Author SHA1 Message Date
poma
fcdaa2d52c remove web3-utils 2020-10-17 05:22:55 +03:00
poma
adf328f81c switch from web3 to ethers 2020-10-16 21:44:09 +03:00
poma
b0e25e800f fix package.json scripts 2020-10-15 02:30:51 +03:00
6 changed files with 500 additions and 2211 deletions

View File

@@ -5,8 +5,8 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"eslint": "eslint --ext .js --ignore-path .gitignore .", "eslint": "eslint --ext .js --ignore-path .gitignore .",
"prettier:check": "npx prettier --check . --config .prettierrc", "prettier:check": "prettier --check . --config .prettierrc",
"prettier:fix": "npx prettier --write . --config .prettierrc", "prettier:fix": "prettier --write . --config .prettierrc",
"lint": "yarn eslint && yarn prettier:check", "lint": "yarn eslint && yarn prettier:check",
"test": "mocha --timeout 300000" "test": "mocha --timeout 300000"
}, },
@@ -18,10 +18,10 @@
], ],
"dependencies": { "dependencies": {
"async-mutex": "^0.2.4", "async-mutex": "^0.2.4",
"bn.js": "^5.1.3",
"ethers": "^5.0.17",
"gas-price-oracle": "^0.1.5", "gas-price-oracle": "^0.1.5",
"web3": "^1.3.0", "web3-core-promievent": "^1.3.0"
"web3-core-promievent": "^1.3.0",
"web3-utils": "^1.3.0"
}, },
"devDependencies": { "devDependencies": {
"chai": "^4.2.0", "chai": "^4.2.0",

View File

@@ -1,22 +1,24 @@
const Web3 = require('web3') const ethers = require('ethers')
const { toWei, toHex, toBN, BN, fromWei } = require('web3-utils') const { parseUnits, formatUnits } = ethers.utils
const BigNumber = ethers.BigNumber
const PromiEvent = require('web3-core-promievent') const PromiEvent = require('web3-core-promievent')
const { sleep, when } = require('./utils') const { sleep, min, max } = require('./utils')
// prettier-ignore
const nonceErrors = [ const nonceErrors = [
'Returned error: Transaction nonce is too low. Try incrementing the nonce.', 'Transaction nonce is too low. Try incrementing the nonce.',
'Returned error: nonce too low', 'nonce too low'
] ]
const gasPriceErrors = [ const gasPriceErrors = [
'Returned error: Transaction gas price supplied is too low. There is another transaction with same nonce in the queue. Try increasing the gas price or incrementing the nonce.', 'Transaction gas price supplied is too low. There is another transaction with same nonce in the queue. Try increasing the gas price or incrementing the nonce.',
'Returned error: replacement transaction underpriced', 'replacement transaction underpriced',
/Returned error: Transaction gas price \d+wei is too low. There is another transaction with same nonce in the queue with gas price: \d+wei. Try increasing the gas price or incrementing the nonce./, /Transaction gas price \d+wei is too low. There is another transaction with same nonce in the queue with gas price: \d+wei. Try increasing the gas price or incrementing the nonce./,
] ]
// prettier-ignore // prettier-ignore
const sameTxErrors = [ const sameTxErrors = [
'Returned error: Transaction with the same hash was already imported.', 'Transaction with the same hash was already imported.',
] ]
class Transaction { class Transaction {
@@ -59,9 +61,9 @@ class Transaction {
this.tx = { ...tx } this.tx = { ...tx }
return return
} }
if (!tx.gas) { if (!tx.gasLimit) {
tx.gas = await this._web3.eth.estimateGas(tx) tx.gasLimit = await this._wallet.estimateGas(tx)
tx.gas = Math.floor(tx.gas * 1.1) tx.gasLimit = Math.floor(tx.gasLimit * this.config.GAS_LIMIT_MULTIPLIER)
} }
tx.nonce = this.tx.nonce // can be different from `this.manager._nonce` tx.nonce = this.tx.nonce // can be different from `this.manager._nonce`
tx.gasPrice = Math.max(this.tx.gasPrice, tx.gasPrice || 0) // start no less than current tx gas price tx.gasPrice = Math.max(this.tx.gasPrice, tx.gasPrice || 0) // start no less than current tx gas price
@@ -80,7 +82,7 @@ class Transaction {
from: this.address, from: this.address,
to: this.address, to: this.address,
value: 0, value: 0,
gas: 21000, gasLimit: 21000,
}) })
} }
@@ -111,19 +113,24 @@ class Transaction {
* @private * @private
*/ */
async _prepare() { async _prepare() {
if (!this.tx.gas || this.config.ESTIMATE_GAS) { if (!this.tx.gasLimit || this.config.ESTIMATE_GAS) {
const gas = await this._web3.eth.estimateGas(this.tx) const gas = await this._wallet.estimateGas(this.tx)
if (!this.tx.gas) { if (!this.tx.gasLimit) {
this.tx.gas = Math.floor(gas * 1.1) this.tx.gasLimit = Math.floor(gas * this.config.GAS_LIMIT_MULTIPLIER)
} }
} }
if (!this.tx.gasPrice) { if (!this.tx.gasPrice) {
this.tx.gasPrice = await this._getGasPrice('fast') this.tx.gasPrice = await this._getGasPrice('fast')
} }
if (!this.manager._nonce) { if (!this.manager._nonce) {
this.manager._nonce = await this._web3.eth.getTransactionCount(this.address, 'latest') this.manager._nonce = await this._getLastNonce()
} }
this.tx.nonce = this.manager._nonce this.tx.nonce = this.manager._nonce
if (!this.manager._chainId) {
const net = await this._provider.getNetwork()
this.manager._chainId = net.chainId
}
this.tx.chainId = this.manager._chainId
} }
/** /**
@@ -134,19 +141,19 @@ class Transaction {
*/ */
async _send() { async _send() {
// todo throw is we attempt to send a tx that attempts to replace already mined tx // todo throw is we attempt to send a tx that attempts to replace already mined tx
const signedTx = await this._web3.eth.accounts.signTransaction(this.tx, this._privateKey) const signedTx = await this._wallet.signTransaction(this.tx)
this.submitTimestamp = Date.now() this.submitTimestamp = Date.now()
this.tx.hash = signedTx.transactionHash const txHash = ethers.utils.keccak256(signedTx)
this.hashes.push(signedTx.transactionHash) this.hashes.push(txHash)
try { try {
await this._broadcast(signedTx.rawTransaction) await this._broadcast(signedTx)
} catch (e) { } catch (e) {
return this._handleSendError(e) return this._handleSendError(e)
} }
this._emitter.emit('transactionHash', signedTx.transactionHash) this._emitter.emit('transactionHash', txHash)
console.log(`Broadcasted transaction ${signedTx.transactionHash}`) console.log(`Broadcasted transaction ${txHash}`)
console.log(this.tx) console.log(this.tx)
} }
@@ -161,7 +168,7 @@ class Transaction {
while (true) { while (true) {
// We are already waiting on certain tx hash // We are already waiting on certain tx hash
if (this.currentTxHash) { if (this.currentTxHash) {
const receipt = await this._web3.eth.getTransactionReceipt(this.currentTxHash) const receipt = await this._provider.getTransactionReceipt(this.currentTxHash)
if (!receipt) { if (!receipt) {
// We were waiting for some tx but it disappeared // We were waiting for some tx but it disappeared
@@ -170,7 +177,7 @@ class Transaction {
continue continue
} }
const currentBlock = await this._web3.eth.getBlockNumber() const currentBlock = await this._provider.getBlockNumber()
const confirmations = Math.max(0, currentBlock - receipt.blockNumber) const confirmations = Math.max(0, currentBlock - receipt.blockNumber)
// todo don't emit repeating confirmation count // todo don't emit repeating confirmation count
this._emitter.emit('confirmations', confirmations) this._emitter.emit('confirmations', confirmations)
@@ -236,7 +243,7 @@ class Transaction {
async _getReceipts() { async _getReceipts() {
for (const hash of this.hashes.reverse()) { for (const hash of this.hashes.reverse()) {
const receipt = await this._web3.eth.getTransactionReceipt(hash) const receipt = await this._provider.getTransactionReceipt(hash)
if (receipt) { if (receipt) {
return receipt return receipt
} }
@@ -248,22 +255,26 @@ class Transaction {
* Broadcasts tx to multiple nodes, waits for tx hash only on main node * Broadcasts tx to multiple nodes, waits for tx hash only on main node
*/ */
_broadcast(rawTx) { _broadcast(rawTx) {
const main = this._web3.eth.sendSignedTransaction(rawTx) const main = this._provider.sendTransaction(rawTx)
for (const node of this._broadcastNodes) { for (const node of this._broadcastNodes) {
try { try {
new Web3(node).eth.sendSignedTransaction(rawTx) new ethers.providers.JsonRpcProvider(node).sendTransaction(rawTx)
} catch (e) { } catch (e) {
console.log(`Failed to send transaction to node ${node}: ${e}`) console.log(`Failed to send transaction to node ${node}: ${e}`)
} }
} }
return when(main, 'transactionHash') return main
} }
_handleSendError(e) { _handleSendError(e) {
console.log('Got error', e) console.log('Got error', e)
if (e.code === 'SERVER_ERROR' && e.error) {
const message = e.error.message
console.log('Error', e.error.code, e.error.message)
// nonce is too low, trying to increase and resubmit // nonce is too low, trying to increase and resubmit
if (this._hasError(e.message, nonceErrors)) { if (this._hasError(message, nonceErrors)) {
console.log(`Nonce ${this.tx.nonce} is too low, increasing and retrying`) console.log(`Nonce ${this.tx.nonce} is too low, increasing and retrying`)
if (this.retries <= this.config.MAX_RETRIES) { if (this.retries <= this.config.MAX_RETRIES) {
this.tx.nonce++ this.tx.nonce++
@@ -273,18 +284,21 @@ class Transaction {
} }
// there is already a pending tx with higher gas price, trying to bump and resubmit // there is already a pending tx with higher gas price, trying to bump and resubmit
if (this._hasError(e.message, gasPriceErrors)) { if (this._hasError(message, gasPriceErrors)) {
console.log(`Gas price ${fromWei(this.tx.gasPrice, 'gwei')} gwei is too low, increasing and retrying`) console.log(
`Gas price ${formatUnits(this.tx.gasPrice, 'gwei')} gwei is too low, increasing and retrying`,
)
this._increaseGasPrice() this._increaseGasPrice()
return this._send() return this._send()
} }
if (this._hasError(e.message, sameTxErrors)) { if (this._hasError(message, sameTxErrors)) {
console.log('Same transaction is already in mempool, skipping submit') console.log('Same transaction is already in mempool, skipping submit')
return // do nothing return // do nothing
} }
}
throw new Error(`Send error: ${e.message}`) throw new Error(`Send error: ${e}`)
} }
/** /**
@@ -300,19 +314,19 @@ class Transaction {
} }
_increaseGasPrice() { _increaseGasPrice() {
const minGweiBump = toBN(toWei(this.config.MIN_GWEI_BUMP.toString(), 'Gwei')) const minGweiBump = parseUnits(this.config.MIN_GWEI_BUMP.toString(), 'gwei')
const oldGasPrice = toBN(this.tx.gasPrice) const oldGasPrice = BigNumber.from(this.tx.gasPrice)
const newGasPrice = BN.max( const newGasPrice = max(
oldGasPrice.mul(toBN(100 + this.config.GAS_BUMP_PERCENTAGE)).div(toBN(100)), oldGasPrice.mul(100 + this.config.GAS_BUMP_PERCENTAGE).div(100),
oldGasPrice.add(minGweiBump), oldGasPrice.add(minGweiBump),
) )
const maxGasPrice = toBN(toWei(this.config.MAX_GAS_PRICE.toString(), 'gwei')) const maxGasPrice = parseUnits(this.config.MAX_GAS_PRICE.toString(), 'gwei')
if (toBN(this.tx.gasPrice).eq(maxGasPrice)) { if (oldGasPrice.eq(maxGasPrice)) {
console.log('Already at max gas price, not bumping') console.log('Already at max gas price, not bumping')
return false return false
} }
this.tx.gasPrice = toHex(BN.min(newGasPrice, maxGasPrice)) this.tx.gasPrice = min(newGasPrice, maxGasPrice).toHexString()
console.log(`Increasing gas price to ${fromWei(this.tx.gasPrice, 'gwei')} gwei`) console.log(`Increasing gas price to ${formatUnits(this.tx.gasPrice, 'gwei')} gwei`)
return true return true
} }
@@ -327,7 +341,7 @@ class Transaction {
const gasPrices = await this._gasPriceOracle.gasPrices() const gasPrices = await this._gasPriceOracle.gasPrices()
const result = gasPrices[type].toString() const result = gasPrices[type].toString()
console.log(`${type} gas price is now ${result} gwei`) console.log(`${type} gas price is now ${result} gwei`)
return toHex(toWei(gasPrices[type].toString(), 'gwei')) return parseUnits(gasPrices[type], 'gwei').toHexString()
} }
/** /**
@@ -337,7 +351,7 @@ class Transaction {
* @private * @private
*/ */
_getLastNonce() { _getLastNonce() {
return this._web3.eth.getTransactionCount(this.address, 'latest') return this._wallet.getTransactionCount('latest')
} }
} }

View File

@@ -1,4 +1,4 @@
const Web3 = require('web3') const ethers = require('ethers')
const { Mutex } = require('async-mutex') const { Mutex } = require('async-mutex')
const { GasPriceOracle } = require('gas-price-oracle') const { GasPriceOracle } = require('gas-price-oracle')
const Transaction = require('./Transaction') const Transaction = require('./Transaction')
@@ -9,6 +9,7 @@ const defaultConfig = {
MIN_GWEI_BUMP: 1, MIN_GWEI_BUMP: 1,
GAS_BUMP_INTERVAL: 1000 * 60 * 5, GAS_BUMP_INTERVAL: 1000 * 60 * 5,
MAX_GAS_PRICE: 1000, MAX_GAS_PRICE: 1000,
GAS_LIMIT_MULTIPLIER: 1.1,
POLL_INTERVAL: 5000, POLL_INTERVAL: 5000,
CONFIRMATIONS: 8, CONFIRMATIONS: 8,
ESTIMATE_GAS: true, ESTIMATE_GAS: true,
@@ -17,12 +18,11 @@ const defaultConfig = {
class TxManager { class TxManager {
constructor({ privateKey, rpcUrl, broadcastNodes = [], config = {} }) { constructor({ privateKey, rpcUrl, broadcastNodes = [], config = {} }) {
this.config = Object.assign({ ...defaultConfig }, config) this.config = Object.assign({ ...defaultConfig }, config)
this._privateKey = '0x' + privateKey this._privateKey = privateKey.startsWith('0x') ? privateKey : '0x' + privateKey
this._web3 = new Web3(rpcUrl) this._provider = new ethers.providers.JsonRpcProvider(rpcUrl)
this._wallet = new ethers.Wallet(this._privateKey, this._provider)
this.address = this._wallet.address
this._broadcastNodes = broadcastNodes this._broadcastNodes = broadcastNodes
this.address = this._web3.eth.accounts.privateKeyToAccount(this._privateKey).address
this._web3.eth.accounts.wallet.add(this._privateKey)
this._web3.eth.defaultAccount = this.address
this._gasPriceOracle = new GasPriceOracle({ defaultRpc: rpcUrl }) this._gasPriceOracle = new GasPriceOracle({ defaultRpc: rpcUrl })
this._mutex = new Mutex() this._mutex = new Mutex()
this._nonce = null this._nonce = null

View File

@@ -1,15 +1,17 @@
const BN = require('bn.js')
const { BigNumber } = require('ethers')
/** /**
* A promise that resolves after `ms` milliseconds * A promise that resolves after `ms` milliseconds
*/ */
const sleep = ms => new Promise(res => setTimeout(res, ms)) const sleep = ms => new Promise(res => setTimeout(res, ms))
/** const max = (a, b) => BigNumber.from(BN.max(new BN(a.toString()), new BN(b.toString())).toString())
* A promise that resolves when the source emits specified event
*/ const min = (a, b) => BigNumber.from(BN.min(new BN(a.toString()), new BN(b.toString())).toString())
const when = (source, event) =>
new Promise((resolve, reject) => source.once(event, resolve).on('error', reject))
module.exports = { module.exports = {
sleep, sleep,
when, max,
min,
} }

View File

@@ -1,6 +1,6 @@
require('dotenv').config() require('dotenv').config()
require('chai').should() require('chai').should()
const { toHex, toWei } = require('web3-utils') const { parseUnits } = require('ethers').utils
const TxManager = require('../src/TxManager') const TxManager = require('../src/TxManager')
// const Transaction = require('../src/Transaction') // const Transaction = require('../src/Transaction')
const { RPC_URL, PRIVATE_KEY } = process.env const { RPC_URL, PRIVATE_KEY } = process.env
@@ -17,11 +17,17 @@ describe('TxManager', () => {
const tx1 = { const tx1 = {
value: 1, value: 1,
gasPrice: toHex(toWei('0.5', 'gwei')), gasPrice: parseUnits('1', 'gwei').toHexString(),
to: '0xA43Ce8Cc89Eff3AA5593c742fC56A30Ef2427CB0', to: '0xA43Ce8Cc89Eff3AA5593c742fC56A30Ef2427CB0',
} }
const tx2 = { const tx2 = {
value: 1,
gasPrice: parseUnits('0.5', 'gwei').toHexString(),
to: '0xA43Ce8Cc89Eff3AA5593c742fC56A30Ef2427CB0',
}
const tx3 = {
value: 2, value: 2,
to: '0x0039F22efB07A647557C7C5d17854CFD6D489eF3', to: '0x0039F22efB07A647557C7C5d17854CFD6D489eF3',
} }
@@ -39,8 +45,20 @@ describe('TxManager', () => {
console.log('receipt', receipt) console.log('receipt', receipt)
}) })
it('should bump gas price', async () => {
const tx = manager.createTx(tx2)
const receipt = await tx
.send()
.on('transactionHash', hash => console.log('hash', hash))
.on('mined', receipt => console.log('Mined in block', receipt.blockNumber))
.on('confirmations', confirmations => console.log('confirmations', confirmations))
console.log('receipt', receipt)
})
it('should cancel', async () => { it('should cancel', async () => {
const tx = manager.createTx(tx1) const tx = manager.createTx(tx2)
setTimeout(() => tx.cancel(), 1000) setTimeout(() => tx.cancel(), 1000)
@@ -54,9 +72,9 @@ describe('TxManager', () => {
}) })
it('should replace', async () => { it('should replace', async () => {
const tx = manager.createTx(tx1) const tx = manager.createTx(tx2)
setTimeout(() => tx.replace(tx2), 1000) setTimeout(() => tx.replace(tx3), 1000)
const receipt = await tx const receipt = await tx
.send() .send()

2515
yarn.lock

File diff suppressed because it is too large Load Diff