14 Commits

Author SHA1 Message Date
Alexey
780df01b43 _handleSendError fix 2020-11-25 22:36:51 +01:00
Alexey
c5e4d76dc5 new 'nonce to low' error; remove console.log 2020-11-25 21:33:34 +01:00
Alexey
8cb2bb0fbe THROW_ON_REVERT feature 2020-11-19 20:33:58 +03:00
Alexey
6bb265d3b9 fix: throw error if transaction execution was failed 2020-11-19 14:37:18 +03:00
Alexey
b940fad5e0 remove an anoying console.log 2020-10-30 11:56:24 +03:00
poma
1634e5fb16 update gas price oracle 2020-10-20 09:39:28 +03:00
poma
62bcd2aa95 fix gas price 2020-10-20 09:28:59 +03:00
poma
1eec6aa329 simplify min and max 2020-10-17 14:37:22 +03:00
poma
715ad59273 fix repository 2020-10-17 05:29:46 +03:00
poma
c6344b40d5 add repository 2020-10-17 05:26:45 +03:00
poma
3b7d5ebd24 update version 2020-10-17 05:25:52 +03:00
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
7 changed files with 525 additions and 2224 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
node_modules node_modules
.env

View File

@@ -1,27 +1,30 @@
{ {
"name": "tx-manager", "name": "tx-manager",
"version": "0.1.1", "version": "0.2.8",
"description": "", "description": "",
"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"
}, },
"keywords": [], "keywords": [],
"author": "Roman Semenov <semenov.roma@gmail.com>", "author": "Roman Semenov <semenov.roma@gmail.com>",
"license": "ISC", "license": "ISC",
"repository": {
"type": "git",
"url": "git://github.com/tornadocash/tx-manager.git"
},
"files": [ "files": [
"src/*" "src/*"
], ],
"dependencies": { "dependencies": {
"async-mutex": "^0.2.4", "async-mutex": "^0.2.4",
"gas-price-oracle": "^0.1.5", "ethers": "^5.0.17",
"web3": "^1.3.0", "gas-price-oracle": "^0.2.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')
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',
'nonce has already been used',
] ]
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,20 +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)
} }
/** /**
@@ -161,7 +167,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,12 +176,15 @@ 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)
if (confirmations >= this.config.CONFIRMATIONS) { if (confirmations >= this.config.CONFIRMATIONS) {
// Tx is mined and has enough confirmations // Tx is mined and has enough confirmations
if (this.config.THROW_ON_REVERT && Number(receipt.status) === 0) {
throw new Error('EVM execution failed, so the transaction was reverted.')
}
return receipt return receipt
} }
@@ -228,7 +237,6 @@ class Transaction {
} }
} }
console.log('Mined. Start waiting for confirmations...')
this._emitter.emit('mined', receipt) this._emitter.emit('mined', receipt)
this.currentTxHash = receipt.transactionHash this.currentTxHash = receipt.transactionHash
} }
@@ -236,7 +244,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,43 +256,47 @@ 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) if (e.error && e.error.code === 'SERVER_ERROR') {
const message = e.error.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++
this.retries++ this.retries++
return this._send()
}
}
// there is already a pending tx with higher gas price, trying to bump and resubmit
if (this._hasError(message, gasPriceErrors)) {
console.log(
`Gas price ${formatUnits(this.tx.gasPrice, 'gwei')} gwei is too low, increasing and retrying`,
)
this._increaseGasPrice()
return this._send() return this._send()
} }
if (this._hasError(message, sameTxErrors)) {
console.log('Same transaction is already in mempool, skipping submit')
return // do nothing
}
} }
// there is already a pending tx with higher gas price, trying to bump and resubmit throw new Error(`Send error: ${e}`)
if (this._hasError(e.message, gasPriceErrors)) {
console.log(`Gas price ${fromWei(this.tx.gasPrice, 'gwei')} gwei is too low, increasing and retrying`)
this._increaseGasPrice()
return this._send()
}
if (this._hasError(e.message, sameTxErrors)) {
console.log('Same transaction is already in mempool, skipping submit')
return // do nothing
}
throw new Error(`Send error: ${e.message}`)
} }
/** /**
@@ -300,19 +312,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 +339,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(result, 'gwei').toHexString()
} }
/** /**
@@ -337,7 +349,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,20 +9,21 @@ 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,
THROW_ON_REVERT: true,
} }
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

@@ -3,13 +3,12 @@
*/ */
const sleep = ms => new Promise(res => setTimeout(res, ms)) const sleep = ms => new Promise(res => setTimeout(res, ms))
/** const max = (a, b) => (a.gt(b) ? a : b)
* A promise that resolves when the source emits specified event
*/ const min = (a, b) => (a.lt(b) ? a : b)
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,15 +17,26 @@ 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',
} }
const tx4 = {
value: 1,
to: '0xA43Ce8Cc89Eff3AA5593c742fC56A30Ef2427CB0',
}
describe('#transaction', () => { describe('#transaction', () => {
it('should work', async () => { it('should work', async () => {
const tx = manager.createTx(tx1) const tx = manager.createTx(tx1)
@@ -39,8 +50,32 @@ describe('TxManager', () => {
console.log('receipt', receipt) console.log('receipt', receipt)
}) })
it('should fetch gas price', async () => {
const tx = manager.createTx(tx4)
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 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 +89,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()

2526
yarn.lock

File diff suppressed because it is too large Load Diff