23 Commits

Author SHA1 Message Date
Danil Kovtonyuk
72a665a19a add gasPriceOracleConfig 2021-06-03 16:16:31 +03:00
Roman Storm
f6a4e93a23 fix mutex 2021-02-16 22:08:12 -08:00
Roman Storm
af7c597af9 Merge branch 'master' of github.com:tornadocash/tx-manager 2021-02-16 21:48:51 -08:00
Roman Storm
221dce3d73 add MAX_GAS_PRICE 2021-02-16 21:48:44 -08:00
poma
8d4bab7fc2 handle bump gas price error 2021-02-17 08:40:14 +03:00
poma
af65d78be9 fix await 2021-02-17 08:39:50 +03:00
Roman Storm
bc0b369095 fix gas price bump 2021-02-16 20:01:30 -08:00
Alexey Pertsev
e1620e15c1 Block gas limit (#1)
update gas-price-oracle dep, add BLOCK_GAS_LIMIT const
2020-12-24 08:39:07 +03:00
poma
414fb28a5e more general fix for tx error 2020-11-26 10:34:40 +03:00
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 573 additions and 2234 deletions

1
.gitignore vendored
View File

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

View File

@@ -1,27 +1,30 @@
{
"name": "tx-manager",
"version": "0.1.1",
"version": "0.3.0",
"description": "",
"main": "index.js",
"scripts": {
"eslint": "eslint --ext .js --ignore-path .gitignore .",
"prettier:check": "npx prettier --check . --config .prettierrc",
"prettier:fix": "npx prettier --write . --config .prettierrc",
"prettier:check": "prettier --check . --config .prettierrc",
"prettier:fix": "prettier --write . --config .prettierrc",
"lint": "yarn eslint && yarn prettier:check",
"test": "mocha --timeout 300000"
},
"keywords": [],
"author": "Roman Semenov <semenov.roma@gmail.com>",
"license": "ISC",
"repository": {
"type": "git",
"url": "git://github.com/tornadocash/tx-manager.git"
},
"files": [
"src/*"
],
"dependencies": {
"async-mutex": "^0.2.4",
"gas-price-oracle": "^0.1.5",
"web3": "^1.3.0",
"web3-core-promievent": "^1.3.0",
"web3-utils": "^1.3.0"
"ethers": "^5.0.17",
"gas-price-oracle": "^0.3.1",
"web3-core-promievent": "^1.3.0"
},
"devDependencies": {
"chai": "^4.2.0",

View File

@@ -1,22 +1,26 @@
const Web3 = require('web3')
const { toWei, toHex, toBN, BN, fromWei } = require('web3-utils')
const ethers = require('ethers')
const { parseUnits, formatUnits } = ethers.utils
const BigNumber = ethers.BigNumber
const PromiEvent = require('web3-core-promievent')
const { sleep, when } = require('./utils')
const { sleep, min, max } = require('./utils')
const nonceErrors = [
'Returned error: Transaction nonce is too low. Try incrementing the nonce.',
'Returned error: nonce too low',
'Transaction nonce is too low. Try incrementing the nonce.',
'nonce too low',
'nonce has already been used',
]
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.',
'Returned error: 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 supplied is too low. There is another transaction with same nonce in the queue. Try increasing the gas price or incrementing the nonce.',
'replacement transaction underpriced',
'transaction underpriced',
/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
const sameTxErrors = [
'Returned error: Transaction with the same hash was already imported.',
'Transaction with the same hash was already imported.',
'already known',
]
class Transaction {
@@ -59,9 +63,10 @@ class Transaction {
this.tx = { ...tx }
return
}
if (!tx.gas) {
tx.gas = await this._web3.eth.estimateGas(tx)
tx.gas = Math.floor(tx.gas * 1.1)
if (!tx.gasLimit) {
tx.gasLimit = await this._wallet.estimateGas(tx)
tx.gasLimit = Math.floor(tx.gasLimit * this.config.GAS_LIMIT_MULTIPLIER)
tx.gasLimit = Math.min(tx.gasLimit, this.config.BLOCK_GAS_LIMIT)
}
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
@@ -80,7 +85,7 @@ class Transaction {
from: this.address,
to: this.address,
value: 0,
gas: 21000,
gasLimit: 21000,
})
}
@@ -91,16 +96,16 @@ class Transaction {
* @private
*/
async _execute() {
await this.manager._mutex.acquire()
const mutexRelease = await this.manager._mutex.acquire()
try {
await this._prepare()
await this._send()
const receipt = this._waitForConfirmations()
const receipt = await this._waitForConfirmations()
// we could have bumped nonce during execution, so get the latest one + 1
this.manager._nonce = this.tx.nonce + 1
return receipt
} finally {
this.manager._mutex.release()
mutexRelease()
}
}
@@ -111,19 +116,32 @@ class Transaction {
* @private
*/
async _prepare() {
if (!this.tx.gas || this.config.ESTIMATE_GAS) {
const gas = await this._web3.eth.estimateGas(this.tx)
if (!this.tx.gas) {
this.tx.gas = Math.floor(gas * 1.1)
if (!this.config.BLOCK_GAS_LIMIT) {
const lastBlock = await this._provider.getBlock('latest')
this.config.BLOCK_GAS_LIMIT = Math.floor(lastBlock.gasLimit.toNumber() * 0.95)
}
if (!this.tx.gasLimit || this.config.ESTIMATE_GAS) {
const gas = await this._wallet.estimateGas(this.tx)
if (!this.tx.gasLimit) {
const gasLimit = Math.floor(gas * this.config.GAS_LIMIT_MULTIPLIER)
this.tx.gasLimit = Math.min(gasLimit, this.config.BLOCK_GAS_LIMIT)
}
}
if (!this.tx.gasPrice) {
this.tx.gasPrice = await this._getGasPrice('fast')
const fastGasPrice = BigNumber.from(await this._getGasPrice('fast'))
const maxGasPrice = parseUnits(this.config.MAX_GAS_PRICE.toString(), 'gwei')
this.tx.gasPrice = min(fastGasPrice, maxGasPrice).toHexString()
}
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
if (!this.manager._chainId) {
const net = await this._provider.getNetwork()
this.manager._chainId = net.chainId
}
this.tx.chainId = this.manager._chainId
}
/**
@@ -134,20 +152,19 @@ class Transaction {
*/
async _send() {
// 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.tx.hash = signedTx.transactionHash
this.hashes.push(signedTx.transactionHash)
const txHash = ethers.utils.keccak256(signedTx)
this.hashes.push(txHash)
try {
await this._broadcast(signedTx.rawTransaction)
await this._broadcast(signedTx)
} catch (e) {
return this._handleSendError(e)
}
this._emitter.emit('transactionHash', signedTx.transactionHash)
console.log(`Broadcasted transaction ${signedTx.transactionHash}`)
console.log(this.tx)
this._emitter.emit('transactionHash', txHash)
console.log(`Broadcasted transaction ${txHash}`)
}
/**
@@ -161,7 +178,7 @@ class Transaction {
while (true) {
// We are already waiting on certain tx hash
if (this.currentTxHash) {
const receipt = await this._web3.eth.getTransactionReceipt(this.currentTxHash)
const receipt = await this._provider.getTransactionReceipt(this.currentTxHash)
if (!receipt) {
// We were waiting for some tx but it disappeared
@@ -170,12 +187,15 @@ class Transaction {
continue
}
const currentBlock = await this._web3.eth.getBlockNumber()
const currentBlock = await this._provider.getBlockNumber()
const confirmations = Math.max(0, currentBlock - receipt.blockNumber)
// todo don't emit repeating confirmation count
this._emitter.emit('confirmations', confirmations)
if (confirmations >= this.config.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
}
@@ -228,7 +248,6 @@ class Transaction {
}
}
console.log('Mined. Start waiting for confirmations...')
this._emitter.emit('mined', receipt)
this.currentTxHash = receipt.transactionHash
}
@@ -236,7 +255,7 @@ class Transaction {
async _getReceipts() {
for (const hash of this.hashes.reverse()) {
const receipt = await this._web3.eth.getTransactionReceipt(hash)
const receipt = await this._provider.getTransactionReceipt(hash)
if (receipt) {
return receipt
}
@@ -248,22 +267,28 @@ class Transaction {
* Broadcasts tx to multiple nodes, waits for tx hash only on main node
*/
_broadcast(rawTx) {
const main = this._web3.eth.sendSignedTransaction(rawTx)
const main = this._provider.sendTransaction(rawTx)
for (const node of this._broadcastNodes) {
try {
new Web3(node).eth.sendSignedTransaction(rawTx)
new ethers.providers.JsonRpcProvider(node).sendTransaction(rawTx)
} catch (e) {
console.log(`Failed to send transaction to node ${node}: ${e}`)
}
}
return when(main, 'transactionHash')
return main
}
_handleSendError(e) {
console.log('Got error', e)
if (e.error.error) {
// Sometimes ethers wraps known errors, unwrap it in this case
e = e.error
}
if (e.error && e.code === 'SERVER_ERROR') {
const message = e.error.message
// 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`)
if (this.retries <= this.config.MAX_RETRIES) {
this.tx.nonce++
@@ -273,18 +298,24 @@ class Transaction {
}
// there is already a pending tx with higher gas price, trying to bump and resubmit
if (this._hasError(e.message, gasPriceErrors)) {
console.log(`Gas price ${fromWei(this.tx.gasPrice, 'gwei')} gwei is too low, increasing and retrying`)
this._increaseGasPrice()
if (this._hasError(message, gasPriceErrors)) {
console.log(
`Gas price ${formatUnits(this.tx.gasPrice, 'gwei')} gwei is too low, increasing and retrying`,
)
if (this._increaseGasPrice()) {
return this._send()
} else {
throw new Error('Already at max gas price, but still not enough to submit the transaction')
}
}
if (this._hasError(e.message, sameTxErrors)) {
if (this._hasError(message, sameTxErrors)) {
console.log('Same transaction is already in mempool, skipping submit')
return // do nothing
}
}
throw new Error(`Send error: ${e.message}`)
throw new Error(`Send error: ${e}`)
}
/**
@@ -300,19 +331,19 @@ class Transaction {
}
_increaseGasPrice() {
const minGweiBump = toBN(toWei(this.config.MIN_GWEI_BUMP.toString(), 'Gwei'))
const oldGasPrice = toBN(this.tx.gasPrice)
const newGasPrice = BN.max(
oldGasPrice.mul(toBN(100 + this.config.GAS_BUMP_PERCENTAGE)).div(toBN(100)),
oldGasPrice.add(minGweiBump),
)
const maxGasPrice = toBN(toWei(this.config.MAX_GAS_PRICE.toString(), 'gwei'))
if (toBN(this.tx.gasPrice).eq(maxGasPrice)) {
const maxGasPrice = parseUnits(this.config.MAX_GAS_PRICE.toString(), 'gwei')
const minGweiBump = parseUnits(this.config.MIN_GWEI_BUMP.toString(), 'gwei')
const oldGasPrice = BigNumber.from(this.tx.gasPrice)
if (oldGasPrice.gte(maxGasPrice)) {
console.log('Already at max gas price, not bumping')
return false
}
this.tx.gasPrice = toHex(BN.min(newGasPrice, maxGasPrice))
console.log(`Increasing gas price to ${fromWei(this.tx.gasPrice, 'gwei')} gwei`)
const newGasPrice = max(
oldGasPrice.mul(100 + this.config.GAS_BUMP_PERCENTAGE).div(100),
oldGasPrice.add(minGweiBump),
)
this.tx.gasPrice = min(newGasPrice, maxGasPrice).toHexString()
console.log(`Increasing gas price to ${formatUnits(this.tx.gasPrice, 'gwei')} gwei`)
return true
}
@@ -327,7 +358,7 @@ class Transaction {
const gasPrices = await this._gasPriceOracle.gasPrices()
const result = gasPrices[type].toString()
console.log(`${type} gas price is now ${result} gwei`)
return toHex(toWei(gasPrices[type].toString(), 'gwei'))
return parseUnits(result, 'gwei').toHexString()
}
/**
@@ -337,7 +368,7 @@ class Transaction {
* @private
*/
_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 { GasPriceOracle } = require('gas-price-oracle')
const Transaction = require('./Transaction')
@@ -9,21 +9,23 @@ const defaultConfig = {
MIN_GWEI_BUMP: 1,
GAS_BUMP_INTERVAL: 1000 * 60 * 5,
MAX_GAS_PRICE: 1000,
GAS_LIMIT_MULTIPLIER: 1.1,
POLL_INTERVAL: 5000,
CONFIRMATIONS: 8,
ESTIMATE_GAS: true,
THROW_ON_REVERT: true,
BLOCK_GAS_LIMIT: null,
}
class TxManager {
constructor({ privateKey, rpcUrl, broadcastNodes = [], config = {} }) {
constructor({ privateKey, rpcUrl, broadcastNodes = [], config = {}, gasPriceOracleConfig = {} }) {
this.config = Object.assign({ ...defaultConfig }, config)
this._privateKey = '0x' + privateKey
this._web3 = new Web3(rpcUrl)
this._privateKey = privateKey.startsWith('0x') ? privateKey : '0x' + privateKey
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.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, ...gasPriceOracleConfig })
this._mutex = new Mutex()
this._nonce = null
}

View File

@@ -3,13 +3,12 @@
*/
const sleep = ms => new Promise(res => setTimeout(res, ms))
/**
* A promise that resolves when the source emits specified event
*/
const when = (source, event) =>
new Promise((resolve, reject) => source.once(event, resolve).on('error', reject))
const max = (a, b) => (a.gt(b) ? a : b)
const min = (a, b) => (a.lt(b) ? a : b)
module.exports = {
sleep,
when,
max,
min,
}

View File

@@ -1,6 +1,6 @@
require('dotenv').config()
require('chai').should()
const { toHex, toWei } = require('web3-utils')
const { parseUnits } = require('ethers').utils
const TxManager = require('../src/TxManager')
// const Transaction = require('../src/Transaction')
const { RPC_URL, PRIVATE_KEY } = process.env
@@ -10,22 +10,33 @@ describe('TxManager', () => {
privateKey: PRIVATE_KEY,
rpcUrl: RPC_URL,
config: {
CONFIRMATIONS: 3,
CONFIRMATIONS: 1,
GAS_BUMP_INTERVAL: 1000 * 15,
},
})
const tx1 = {
value: 1,
gasPrice: toHex(toWei('0.5', 'gwei')),
gasPrice: parseUnits('1', 'gwei').toHexString(),
to: '0xA43Ce8Cc89Eff3AA5593c742fC56A30Ef2427CB0',
}
const tx2 = {
value: 1,
gasPrice: parseUnits('0.5', 'gwei').toHexString(),
to: '0xA43Ce8Cc89Eff3AA5593c742fC56A30Ef2427CB0',
}
const tx3 = {
value: 2,
to: '0x0039F22efB07A647557C7C5d17854CFD6D489eF3',
}
const tx4 = {
value: 1,
to: '0xA43Ce8Cc89Eff3AA5593c742fC56A30Ef2427CB0',
}
describe('#transaction', () => {
it('should work', async () => {
const tx = manager.createTx(tx1)
@@ -39,8 +50,32 @@ describe('TxManager', () => {
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 () => {
const tx = manager.createTx(tx1)
const tx = manager.createTx(tx2)
setTimeout(() => tx.cancel(), 1000)
@@ -54,9 +89,9 @@ describe('TxManager', () => {
})
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
.send()
@@ -66,5 +101,23 @@ describe('TxManager', () => {
console.log('receipt', receipt)
})
it.only('should send multiple txs', async () => {
const genTx = value => ({
value,
to: '0x0039F22efB07A647557C7C5d17854CFD6D489eF3',
})
await Promise.all([
manager.createTx(genTx(1)).send(),
manager.createTx(genTx(2)).send(),
manager.createTx(genTx(3)).send(),
manager.createTx(genTx(4)).send(),
manager.createTx(genTx(5)).send(),
manager.createTx(genTx(6)).send(),
manager.createTx(genTx(7)).send(),
manager.createTx(genTx(8)).send(),
manager.createTx(genTx(9)).send(),
manager.createTx(genTx(10)).send(),
])
})
})
})

2526
yarn.lock

File diff suppressed because it is too large Load Diff