11 Commits

Author SHA1 Message Date
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 530 additions and 2223 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.2.5",
"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.2.0",
"web3-core-promievent": "^1.3.0"
},
"devDependencies": {
"chai": "^4.2.0",

View File

@@ -1,22 +1,24 @@
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')
// prettier-ignore
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'
]
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 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.',
]
class Transaction {
@@ -59,9 +61,9 @@ 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.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 +82,7 @@ class Transaction {
from: this.address,
to: this.address,
value: 0,
gas: 21000,
gasLimit: 21000,
})
}
@@ -111,19 +113,24 @@ 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.tx.gasLimit || this.config.ESTIMATE_GAS) {
const gas = await this._wallet.estimateGas(this.tx)
if (!this.tx.gasLimit) {
this.tx.gasLimit = Math.floor(gas * this.config.GAS_LIMIT_MULTIPLIER)
}
}
if (!this.tx.gasPrice) {
this.tx.gasPrice = await this._getGasPrice('fast')
}
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 +141,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 +167,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
@@ -169,8 +175,11 @@ class Transaction {
this.currentTxHash = null
continue
}
if (Number(receipt.status) === 0) {
throw new Error('Transaction failed')
}
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)
@@ -228,7 +237,10 @@ class Transaction {
}
}
console.log('Mined. Start waiting for confirmations...')
if (Number(receipt.status) === 0) {
throw new Error('Transaction failed')
}
this._emitter.emit('mined', receipt)
this.currentTxHash = receipt.transactionHash
}
@@ -236,7 +248,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,43 +260,50 @@ 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)
// nonce is too low, trying to increase and resubmit
if (this._hasError(e.message, nonceErrors)) {
console.log(`Nonce ${this.tx.nonce} is too low, increasing and retrying`)
if (this.retries <= this.config.MAX_RETRIES) {
this.tx.nonce++
this.retries++
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
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++
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()
}
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
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}`)
throw new Error(`Send error: ${e}`)
}
/**
@@ -300,19 +319,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)),
const minGweiBump = parseUnits(this.config.MIN_GWEI_BUMP.toString(), 'gwei')
const oldGasPrice = BigNumber.from(this.tx.gasPrice)
const newGasPrice = max(
oldGasPrice.mul(100 + this.config.GAS_BUMP_PERCENTAGE).div(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')
if (oldGasPrice.eq(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`)
this.tx.gasPrice = min(newGasPrice, maxGasPrice).toHexString()
console.log(`Increasing gas price to ${formatUnits(this.tx.gasPrice, 'gwei')} gwei`)
return true
}
@@ -327,7 +346,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 +356,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,6 +9,7 @@ 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,
@@ -17,12 +18,11 @@ const defaultConfig = {
class TxManager {
constructor({ privateKey, rpcUrl, broadcastNodes = [], config = {} }) {
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._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
@@ -17,15 +17,26 @@ describe('TxManager', () => {
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()

2526
yarn.lock

File diff suppressed because it is too large Load Diff