Compare commits

..

4 Commits

Author SHA1 Message Date
Alexey
2b38927743 decrease default BLOCK_GAS_LIMIT 2020-12-23 20:03:20 -06:00
Alexey
ab82b37e0d move BLOCK_GAS_LIMIT init to _prepare 2020-12-23 19:39:09 -06:00
Alexey
30b32a61d1 adds BLOCK_GAS_LIMIT const 2020-12-23 19:08:36 -06:00
Alexey
682f2b03c8 update gas-price-oracle dep 2020-12-23 19:07:41 -06:00
11 changed files with 654 additions and 3296 deletions

View File

@ -1,6 +1,3 @@
# Needed for tests only # Needed for tests only
RPC_URL=https://kovan.infura.io/v3/... RPC_URL=https://kovan.infura.io/v3/...
PRIVATE_KEY=... PRIVATE_KEY=...
ETHERSCAN_API_KEY=...
ALCHEMY_API_KEY=...
INFURA_API_KEY=...

View File

@ -11,7 +11,7 @@
"SharedArrayBuffer": "readonly" "SharedArrayBuffer": "readonly"
}, },
"parserOptions": { "parserOptions": {
"ecmaVersion": 2020 "ecmaVersion": 2018
}, },
"rules": { "rules": {
"indent": [ "indent": [
@ -22,13 +22,7 @@
} }
], ],
"linebreak-style": ["error", "unix"], "linebreak-style": ["error", "unix"],
"quotes": [ "quotes": ["error", "single", { "avoidEscape": true }],
"error",
"single",
{
"avoidEscape": true
}
],
"semi": ["error", "never"], "semi": ["error", "never"],
"object-curly-spacing": ["error", "always"], "object-curly-spacing": ["error", "always"],
"require-await": "error", "require-await": "error",

1
.npmrc
View File

@ -1 +0,0 @@
@tornado:registry=https://git.tornado.ws/api/packages/tornado-packages/npm/

124
index.d.ts vendored
View File

@ -1,124 +0,0 @@
import { BigNumberish, providers, Wallet } from 'ethers'
import { EventEmitter } from 'eventemitter3'
import { TransactionReceipt } from '@ethersproject/abstract-provider'
import PromiEvent from 'web3-core-promievent'
import { GasOracleOptions, GasPriceOracle } from '@tornado/gas-price-oracle'
import { Mutex } from 'async-mutex'
import { Provider } from '@ethersproject/providers'
export interface TransactionData {
to: string
from?: string
nonce?: number
gasLimit?: BigNumberish
gasPrice?: BigNumberish
data?: string
value: BigNumberish
chainId?: number
type?: number
maxFeePerGas?: BigNumberish
maxPriorityFeePerGas?: BigNumberish
}
export interface TxManagerConfig {
MAX_RETRIES?: number
GAS_BUMP_PERCENTAGE?: number
MIN_GWEI_BUMP?: number
GAS_BUMP_INTERVAL?: number
MAX_GAS_PRICE?: number
GAS_LIMIT_MULTIPLIER?: number
POLL_INTERVAL?: number
CONFIRMATIONS?: number
ESTIMATE_GAS?: boolean
THROW_ON_REVERT?: boolean
BLOCK_GAS_LIMIT?: number
BASE_FEE_RESERVE_PERCENTAGE?: number
ENABLE_EIP1559?: boolean
DEFAULT_PRIORITY_FEE?: number
}
export interface TxManagerParams {
privateKey: string
rpcUrl: string
broadcastNodes?: string[]
config?: TxManagerConfig
gasPriceOracleConfig?: GasOracleOptions
provider?: Provider
}
export class TxManager {
private _privateKey: string
config: TxManagerConfig
address: string
_provider: providers.JsonRpcProvider
_wallet: Wallet
_broadcastNodes: string[]
_gasPriceOracle: GasPriceOracle
_mutex: Mutex
_nonce: number
constructor(params?: TxManagerParams)
createTx(tx: TransactionData): Transaction
}
export type GasParams = {
maxFeePerGas?: string
maxPriorityFeePerGas?: string
gasPrice?: string
type: number
}
export type TxManagerEvents = keyof MessageEvents
export type MessageEvents = {
error: (error: Error) => void
transactionHash: (transactionHash: string) => void
mined: (receipt: TransactionReceipt) => void
confirmations: (confirmations: number) => void
}
type TEventEmitter = typeof EventEmitter
declare interface TxManagerEventEmitter extends TEventEmitter {
on<U extends TxManagerEvents>(event: U, listener: MessageEvents[U]): this
on(event: 'confirmations', listener: MessageEvents['confirmations']): Promise<TransactionReceipt>
emit<U extends TxManagerEvents>(event: U, ...args: Parameters<MessageEvents[U]>): boolean
}
export class Transaction {
manager: TxManager
tx: TransactionData
private _promise: typeof PromiEvent
private _emitter: TxManagerEventEmitter
executed: boolean
retries: number
currentTxHash: string
hashes: string[]
constructor(tx: TransactionData, manager: TxManager)
send(): TxManagerEventEmitter
replace(tx: TransactionData): Promise<void>
cancel(): this
private _prepare(): Promise<void>
private _send(): Promise<void>
private _execute(): Promise<TransactionReceipt>
private _waitForConfirmations(): Promise<TransactionReceipt>
private _getReceipts(): Promise<TransactionReceipt>
private _increaseGasPrice(): boolean
private _hasError(message: string, errors: (string | RegExp)[]): boolean
private _getLastNonce(): Promise<number>
private _getGasParams(): Promise<GasParams>
}

View File

@ -1,12 +1,8 @@
{ {
"name": "@tornado/tx-manager", "name": "tx-manager",
"version": "0.4.9", "version": "0.2.9",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"types": "index.d.ts",
"engines": {
"node": ">=14.0.0"
},
"scripts": { "scripts": {
"eslint": "eslint --ext .js --ignore-path .gitignore .", "eslint": "eslint --ext .js --ignore-path .gitignore .",
"prettier:check": "prettier --check . --config .prettierrc", "prettier:check": "prettier --check . --config .prettierrc",
@ -17,15 +13,17 @@
"keywords": [], "keywords": [],
"author": "Roman Semenov <semenov.roma@gmail.com>", "author": "Roman Semenov <semenov.roma@gmail.com>",
"license": "ISC", "license": "ISC",
"repository": "https://git.tornado.ws/tornado-packages/tx-manager.git", "repository": {
"type": "git",
"url": "git://github.com/tornadocash/tx-manager.git"
},
"files": [ "files": [
"src/*", "src/*"
"index.d.ts"
], ],
"dependencies": { "dependencies": {
"@tornado/gas-price-oracle": "^0.5.3",
"async-mutex": "^0.2.4", "async-mutex": "^0.2.4",
"ethers": "^5.4.6", "ethers": "^5.0.17",
"gas-price-oracle": "^0.2.2",
"web3-core-promievent": "^1.3.0" "web3-core-promievent": "^1.3.0"
}, },
"devDependencies": { "devDependencies": {
@ -36,7 +34,6 @@
"eslint-plugin-prettier": "^3.1.4", "eslint-plugin-prettier": "^3.1.4",
"mocha": "^8.1.3", "mocha": "^8.1.3",
"prettier": "^2.1.2", "prettier": "^2.1.2",
"web3": "^1.7.4",
"why-is-node-running": "^2.2.0" "why-is-node-running": "^2.2.0"
} }
} }

View File

@ -6,37 +6,31 @@ const { sleep, min, max } = require('./utils')
const nonceErrors = [ const nonceErrors = [
'Transaction nonce is too low. Try incrementing the nonce.', 'Transaction nonce is too low. Try incrementing the nonce.',
/nonce too low/i, 'nonce too low',
'nonce has already been used', 'nonce has already been used',
/OldNonce/,
'invalid transaction nonce',
] ]
const gasPriceErrors = [ const gasPriceErrors = [
'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.',
/transaction underpriced/, 'replacement transaction underpriced',
/fee too low/i, '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./, /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./,
/FeeTooLow/,
/max fee per gas less than block base fee/,
] ]
// prettier-ignore // prettier-ignore
const sameTxErrors = [ const sameTxErrors = [
'Transaction with the same hash was already imported.', 'Transaction with the same hash was already imported.',
'already known', 'already known',
'AlreadyKnown',
'Known transaction',
] ]
class Transaction { class Transaction {
constructor(tx, manager) { constructor(tx, manager) {
Object.assign(this, manager)
this.manager = manager this.manager = manager
this.tx = { ...tx } this.tx = { ...tx }
this._promise = PromiEvent() this._promise = PromiEvent()
this._emitter = this._promise.eventEmitter this._emitter = this._promise.eventEmitter
this.executed = false this.executed = false
this.replaced = false
this.retries = 0 this.retries = 0
this.currentTxHash = null this.currentTxHash = null
// store all submitted hashes to catch cases when an old tx is mined // store all submitted hashes to catch cases when an old tx is mined
@ -63,47 +57,22 @@ class Transaction {
*/ */
async replace(tx) { async replace(tx) {
// todo throw error if the current transaction is mined already // todo throw error if the current transaction is mined already
// if (this.currentTxHash) {
// throw new Error('Previous transaction was mined')
// }
console.log('Replacing current transaction') console.log('Replacing current transaction')
if (!this.executed) { if (!this.executed) {
// Tx was not executed yet, just replace it // Tx was not executed yet, just replace it
this.tx = { ...tx } this.tx = { ...tx }
return return
} }
if (!tx.gasLimit) { if (!tx.gasLimit) {
const estimatedGasLimit = await this._estimateGas(tx) tx.gasLimit = await this._wallet.estimateGas(tx)
const gasLimit = estimatedGasLimit tx.gasLimit = Math.floor(tx.gasLimit * this.config.GAS_LIMIT_MULTIPLIER)
.mul(Math.floor(this.manager.config.GAS_LIMIT_MULTIPLIER * 100)) tx.gasLimit = Math.min(tx.gasLimit, this.config.BLOCK_GAS_LIMIT)
.div(100)
tx.gasLimit = this.manager.config.BLOCK_GAS_LIMIT
? min(gasLimit, this.manager.config.BLOCK_GAS_LIMIT)
: gasLimit
} }
tx.chainId = this.tx.chainId
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
// start no less than current tx gas params
if (this.tx.gasPrice) {
tx.gasPrice = max(this.tx.gasPrice, tx.gasPrice || 0)
} else if (this.tx.maxFeePerGas) {
tx.maxFeePerGas = max(this.tx.maxFeePerGas, tx.maxFeePerGas || 0)
tx.maxPriorityFeePerGas = max(this.tx.maxPriorityFeePerGas, tx.maxPriorityFeePerGas || 0)
}
this.tx = { ...tx } this.tx = { ...tx }
await this._prepare()
if (tx.gasPrice || tx.maxFeePerGas) {
this._increaseGasPrice() this._increaseGasPrice()
}
this.replaced = true
await this._send() await this._send()
} }
@ -113,9 +82,10 @@ class Transaction {
cancel() { cancel() {
console.log('Canceling the transaction') console.log('Canceling the transaction')
return this.replace({ return this.replace({
from: this.manager.address, from: this.address,
to: this.manager.address, to: this.address,
value: 0, value: 0,
gasLimit: 21000,
}) })
} }
@ -126,16 +96,16 @@ class Transaction {
* @private * @private
*/ */
async _execute() { async _execute() {
const mutexRelease = await this.manager._mutex.acquire() await this.manager._mutex.acquire()
try { try {
await this._prepare() await this._prepare()
await this._send() await this._send()
const receipt = await this._waitForConfirmations() const receipt = this._waitForConfirmations()
// we could have bumped nonce during execution, so get the latest one + 1 // we could have bumped nonce during execution, so get the latest one + 1
this.manager._nonce = this.tx.nonce + 1 this.manager._nonce = this.tx.nonce + 1
return receipt return receipt
} finally { } finally {
mutexRelease() this.manager._mutex.release()
} }
} }
@ -146,40 +116,30 @@ class Transaction {
* @private * @private
*/ */
async _prepare() { async _prepare() {
if (!this.manager.config.BLOCK_GAS_LIMIT) { if (!this.config.BLOCK_GAS_LIMIT) {
const lastBlock = await this.manager._provider.getBlock('latest') const lastBlock = await this._provider.getBlock('latest')
this.manager.config.BLOCK_GAS_LIMIT = Math.floor(lastBlock.gasLimit.toNumber() * 0.95) this.config.BLOCK_GAS_LIMIT = Math.floor(lastBlock.gasLimit.toNumber() * 0.95)
} }
if (!this.manager._chainId) { if (!this.tx.gasLimit || this.config.ESTIMATE_GAS) {
const net = await this.manager._provider.getNetwork() const gas = await this._wallet.estimateGas(this.tx)
this.manager._chainId = net.chainId
}
if (!this.tx.chainId) {
this.tx.chainId = this.manager._chainId
}
if (!this.tx.gasLimit || this.manager.config.ESTIMATE_GAS) {
const gas = await this._estimateGas(this.tx)
if (!this.tx.gasLimit) { if (!this.tx.gasLimit) {
const gasLimit = Math.floor(gas * this.manager.config.GAS_LIMIT_MULTIPLIER) const gasLimit = Math.floor(gas * this.config.GAS_LIMIT_MULTIPLIER)
this.tx.gasLimit = Math.min(gasLimit, this.manager.config.BLOCK_GAS_LIMIT) this.tx.gasLimit = Math.min(gasLimit, this.config.BLOCK_GAS_LIMIT)
} }
} }
if (!this.tx.gasPrice) {
this.tx.gasPrice = await this._getGasPrice('fast')
}
if (!this.manager._nonce) { if (!this.manager._nonce) {
this.manager._nonce = await this._getLastNonce() this.manager._nonce = await this._getLastNonce()
} }
if (!this.tx.nonce) {
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
if (!this.tx.gasPrice && !this.tx.maxFeePerGas) {
const gasParams = await this._getGasParams()
this.tx = Object.assign(this.tx, gasParams)
}
} }
/** /**
@ -190,7 +150,7 @@ 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.manager._wallet.signTransaction(this.tx) const signedTx = await this._wallet.signTransaction(this.tx)
this.submitTimestamp = Date.now() this.submitTimestamp = Date.now()
const txHash = ethers.utils.keccak256(signedTx) const txHash = ethers.utils.keccak256(signedTx)
this.hashes.push(txHash) this.hashes.push(txHash)
@ -198,7 +158,7 @@ class Transaction {
try { try {
await this._broadcast(signedTx) await this._broadcast(signedTx)
} catch (e) { } catch (e) {
return this._handleRpcError(e, '_send') return this._handleSendError(e)
} }
this._emitter.emit('transactionHash', txHash) this._emitter.emit('transactionHash', txHash)
@ -216,7 +176,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.manager._provider.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
@ -225,20 +185,20 @@ class Transaction {
continue continue
} }
const currentBlock = await this.manager._provider.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.manager.config.CONFIRMATIONS) { if (confirmations >= this.config.CONFIRMATIONS) {
// Tx is mined and has enough confirmations // Tx is mined and has enough confirmations
if (this.manager.config.THROW_ON_REVERT && Number(receipt.status) === 0) { if (this.config.THROW_ON_REVERT && Number(receipt.status) === 0) {
throw new Error('EVM execution failed, so the transaction was reverted.') throw new Error('EVM execution failed, so the transaction was reverted.')
} }
return receipt return receipt
} }
// Tx is mined but doesn't have enough confirmations yet, keep waiting // Tx is mined but doesn't have enough confirmations yet, keep waiting
await sleep(this.manager.config.POLL_INTERVAL) await sleep(this.config.POLL_INTERVAL)
continue continue
} }
@ -247,15 +207,15 @@ class Transaction {
// todo optionally run estimateGas on each iteration and cancel the transaction if it fails // todo optionally run estimateGas on each iteration and cancel the transaction if it fails
// We were waiting too long, increase gas price and resubmit // We were waiting too long, increase gas price and resubmit
if (Date.now() - this.submitTimestamp >= this.manager.config.GAS_BUMP_INTERVAL) { if (Date.now() - this.submitTimestamp >= this.config.GAS_BUMP_INTERVAL) {
if (this._increaseGasPrice()) { if (this._increaseGasPrice()) {
console.log('Resubmitting with higher gas params') console.log('Resubmitting with higher gas price')
await this._send() await this._send()
continue continue
} }
} }
// Tx is still pending, keep waiting // Tx is still pending, keep waiting
await sleep(this.manager.config.POLL_INTERVAL) await sleep(this.config.POLL_INTERVAL)
continue continue
} }
@ -293,7 +253,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.manager._provider.getTransactionReceipt(hash) const receipt = await this._provider.getTransactionReceipt(hash)
if (receipt) { if (receipt) {
return receipt return receipt
} }
@ -304,11 +264,11 @@ 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
*/ */
async _broadcast(rawTx) { _broadcast(rawTx) {
const main = await this.manager._provider.sendTransaction(rawTx) const main = this._provider.sendTransaction(rawTx)
for (const node of this.manager._broadcastNodes) { for (const node of this._broadcastNodes) {
try { try {
await new ethers.providers.JsonRpcProvider(node).sendTransaction(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}`)
} }
@ -316,48 +276,39 @@ class Transaction {
return main return main
} }
_handleRpcError(e, method) { _handleSendError(e) {
if (e.error?.error) { if (e.error.error) {
// Sometimes ethers wraps known errors, unwrap it in this case // Sometimes ethers wraps known errors, unwrap it in this case
e = e.error e = e.error
} }
// web3 provider not wrapping message if (e.error && e.code === 'SERVER_ERROR') {
const message = e.error?.message || e.message const message = e.error.message
// nonce is too low, trying to increase and resubmit // nonce is too low, trying to increase and resubmit
if (this._hasError(message, nonceErrors)) { if (this._hasError(message, nonceErrors)) {
if (this.replaced) {
console.log('Transaction with the same nonce was mined')
return // do nothing
}
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.manager.config.MAX_RETRIES) { if (this.retries <= this.config.MAX_RETRIES) {
this.tx.nonce++ this.tx.nonce++
this.retries++ this.retries++
return this[method]() return this._send()
} }
} }
// 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(message, gasPriceErrors)) { if (this._hasError(message, gasPriceErrors)) {
console.log( console.log(
`Gas price ${formatUnits( `Gas price ${formatUnits(this.tx.gasPrice, 'gwei')} gwei is too low, increasing and retrying`,
this.tx.gasPrice || this.tx.maxFeePerGas,
'gwei',
)} gwei is too low, increasing and retrying`,
) )
if (this._increaseGasPrice()) { this._increaseGasPrice()
return this[method]() return this._send()
} else {
throw new Error('Already at max gas price, but still not enough to submit the transaction')
}
} }
if (this._hasError(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}`) throw new Error(`Send error: ${e}`)
} }
@ -375,50 +326,36 @@ class Transaction {
} }
_increaseGasPrice() { _increaseGasPrice() {
const maxGasPrice = parseUnits(this.manager.config.MAX_GAS_PRICE.toString(), 'gwei') const minGweiBump = parseUnits(this.config.MIN_GWEI_BUMP.toString(), 'gwei')
const minGweiBump = parseUnits(this.manager.config.MIN_GWEI_BUMP.toString(), 'gwei')
if (this.tx.gasPrice) {
const oldGasPrice = BigNumber.from(this.tx.gasPrice) const oldGasPrice = BigNumber.from(this.tx.gasPrice)
if (oldGasPrice.gte(maxGasPrice)) { const newGasPrice = max(
oldGasPrice.mul(100 + this.config.GAS_BUMP_PERCENTAGE).div(100),
oldGasPrice.add(minGweiBump),
)
const maxGasPrice = parseUnits(this.config.MAX_GAS_PRICE.toString(), 'gwei')
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
} }
const newGasPrice = max(
oldGasPrice.mul(100 + this.manager.config.GAS_BUMP_PERCENTAGE).div(100),
oldGasPrice.add(minGweiBump),
)
this.tx.gasPrice = min(newGasPrice, maxGasPrice).toHexString() this.tx.gasPrice = min(newGasPrice, maxGasPrice).toHexString()
console.log(`Increasing gas price to ${formatUnits(this.tx.gasPrice, 'gwei')} gwei`) console.log(`Increasing gas price to ${formatUnits(this.tx.gasPrice, 'gwei')} gwei`)
} else {
const oldMaxFeePerGas = BigNumber.from(this.tx.maxFeePerGas)
const oldMaxPriorityFeePerGas = BigNumber.from(this.tx.maxPriorityFeePerGas)
if (oldMaxFeePerGas.gte(maxGasPrice)) {
console.log('Already at max fee per gas, not bumping')
return false
}
const newMaxFeePerGas = max(
oldMaxFeePerGas.mul(100 + this.manager.config.GAS_BUMP_PERCENTAGE).div(100),
oldMaxFeePerGas.add(minGweiBump),
)
const newMaxPriorityFeePerGas = max(
oldMaxPriorityFeePerGas.mul(100 + this.manager.config.GAS_BUMP_PERCENTAGE).div(100),
oldMaxPriorityFeePerGas.add(minGweiBump),
)
const maxFeePerGas = min(newMaxFeePerGas, maxGasPrice)
this.tx.maxFeePerGas = maxFeePerGas.toHexString()
this.tx.maxPriorityFeePerGas = min(newMaxPriorityFeePerGas, maxFeePerGas).toHexString()
console.log(`Increasing maxFeePerGas to ${formatUnits(this.tx.maxFeePerGas, 'gwei')} gwei`)
}
return true return true
} }
/**
* Fetches gas price from the oracle
*
* @param {'instant'|'fast'|'normal'|'slow'} type
* @returns {Promise<string>} A hex string representing gas price in wei
* @private
*/
async _getGasPrice(type) {
const gasPrices = await this._gasPriceOracle.gasPrices()
const result = gasPrices[type].toString()
console.log(`${type} gas price is now ${result} gwei`)
return parseUnits(result, 'gwei').toHexString()
}
/** /**
* Gets current nonce for the current account, ignoring any pending transactions * Gets current nonce for the current account, ignoring any pending transactions
* *
@ -426,36 +363,7 @@ class Transaction {
* @private * @private
*/ */
_getLastNonce() { _getLastNonce() {
return this.manager._wallet.getTransactionCount('latest') return this._wallet.getTransactionCount('latest')
}
/**
* Choose network gas params
*
* @returns {Promise<object>}
* @private
*/
async _getGasParams() {
const maxGasPrice = parseUnits(this.manager.config.MAX_GAS_PRICE.toString(), 'gwei')
const gasParams = await this.manager._gasPriceOracle.getTxGasParams({
isLegacy: !this.manager.config.ENABLE_EIP1559,
})
if (gasParams.gasPrice) {
gasParams.gasPrice = min(gasParams.gasPrice, maxGasPrice)
} else {
gasParams.maxFeePerGas = min(gasParams?.maxFeePerGas, maxGasPrice)
gasParams.maxPriorityFeePerGas = min(gasParams?.maxPriorityFeePerGas, maxGasPrice)
}
gasParams.type = gasParams?.maxFeePerGas ? 2 : 0
return gasParams
}
async _estimateGas(tx) {
try {
return await this.manager._wallet.estimateGas(tx)
} catch (e) {
return this._handleRpcError(e, '_estimateGas')
}
} }
} }

View File

@ -1,6 +1,6 @@
const ethers = require('ethers') const ethers = require('ethers')
const { Mutex } = require('async-mutex') const { Mutex } = require('async-mutex')
const { GasPriceOracle } = require('@tornado/gas-price-oracle') const { GasPriceOracle } = require('gas-price-oracle')
const Transaction = require('./Transaction') const Transaction = require('./Transaction')
const defaultConfig = { const defaultConfig = {
@ -15,20 +15,17 @@ const defaultConfig = {
ESTIMATE_GAS: true, ESTIMATE_GAS: true,
THROW_ON_REVERT: true, THROW_ON_REVERT: true,
BLOCK_GAS_LIMIT: null, BLOCK_GAS_LIMIT: null,
ENABLE_EIP1559: true,
DEFAULT_PRIORITY_FEE: 3,
BASE_FEE_RESERVE_PERCENTAGE: 50,
} }
class TxManager { class TxManager {
constructor({ privateKey, rpcUrl, broadcastNodes = [], config = {}, gasPriceOracleConfig = {}, provider }) { constructor({ privateKey, rpcUrl, broadcastNodes = [], config = {} }) {
this.config = Object.assign({ ...defaultConfig }, config) this.config = Object.assign({ ...defaultConfig }, config)
this._privateKey = privateKey.startsWith('0x') ? privateKey : '0x' + privateKey this._privateKey = privateKey.startsWith('0x') ? privateKey : '0x' + privateKey
this._provider = provider || new ethers.providers.JsonRpcProvider(rpcUrl) this._provider = new ethers.providers.JsonRpcProvider(rpcUrl)
this._wallet = new ethers.Wallet(this._privateKey, this._provider) this._wallet = new ethers.Wallet(this._privateKey, this._provider)
this.address = this._wallet.address this.address = this._wallet.address
this._broadcastNodes = broadcastNodes this._broadcastNodes = broadcastNodes
this._gasPriceOracle = new GasPriceOracle({ defaultRpc: rpcUrl, ...gasPriceOracleConfig }) this._gasPriceOracle = new GasPriceOracle({ defaultRpc: rpcUrl })
this._mutex = new Mutex() this._mutex = new Mutex()
this._nonce = null this._nonce = null
} }

7
src/utils.d.ts vendored
View File

@ -1,7 +0,0 @@
import { BigNumberish } from 'ethers'
export function sleep(ms: number): Promise<any>
export function max(a: BigNumberish, b: BigNumberish): BigNumberish
export function min(a: BigNumberish, b: BigNumberish): BigNumberish

View File

@ -1,12 +1,11 @@
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(a).gt(b) ? a : b) const max = (a, b) => (a.gt(b) ? a : b)
const min = (a, b) => (BigNumber.from(a).lt(b) ? a : b) const min = (a, b) => (a.lt(b) ? a : b)
module.exports = { module.exports = {
sleep, sleep,

View File

@ -1,190 +1,105 @@
require('dotenv').config() require('dotenv').config()
require('chai').should() require('chai').should()
const { providers } = require('ethers')
const { parseUnits } = require('ethers').utils const { parseUnits } = require('ethers').utils
const TxManager = require('../src/TxManager') const TxManager = require('../src/TxManager')
const Web3 = require('web3') // const Transaction = require('../src/Transaction')
const { RPC_URL, PRIVATE_KEY } = process.env const { RPC_URL, PRIVATE_KEY } = process.env
const tx1 = { describe('TxManager', () => {
value: 1, const manager = new TxManager({
gasPrice: parseUnits('2', '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',
}
const tx5 = {
value: 1,
to: '0xA43Ce8Cc89Eff3AA5593c742fC56A30Ef2427CB0',
maxFeePerGas: parseUnits('7', 'gwei').toHexString(),
maxPriorityFeePerGas: parseUnits('1', 'gwei').toHexString(),
type: 2,
}
const defaultOptions = {
privateKey: PRIVATE_KEY, privateKey: PRIVATE_KEY,
rpcUrl: RPC_URL, rpcUrl: RPC_URL,
config: { config: {
CONFIRMATIONS: 1, CONFIRMATIONS: 1,
GAS_BUMP_INTERVAL: 1000 * 20, GAS_BUMP_INTERVAL: 1000 * 15,
}, },
gasPriceOracleConfig: { })
chainId: 1,
defaultRpc: RPC_URL,
},
provider: undefined,
}
const getOptions = async () => { const tx1 = {
const provider = new providers.JsonRpcProvider(RPC_URL) value: 1,
const network = await provider.getNetwork() gasPrice: parseUnits('1', 'gwei').toHexString(),
const options = { ...defaultOptions } to: '0xA43Ce8Cc89Eff3AA5593c742fC56A30Ef2427CB0',
return { network, provider, options } }
}
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)
const sendTx = async tx => {
const receipt = await tx const receipt = await tx
.send() .send()
.on('transactionHash', hash => console.log('hash', hash)) .on('transactionHash', hash => console.log('hash', hash))
.on('mined', receipt => console.log('Mined in block', receipt.blockNumber)) .on('mined', receipt => console.log('Mined in block', receipt.blockNumber))
.on('confirmations', confirmations => console.log('confirmations', confirmations)) .on('confirmations', confirmations => console.log('confirmations', confirmations))
console.log('receipt', receipt) console.log('receipt', receipt)
return receipt
}
const transactionTests = () => {
it('should work legacy tx', async () => {
const tx = this.manager.createTx(tx1)
const receipt = await sendTx(tx)
receipt.type.should.equal(0)
}) })
it('should work eip-1559 tx', async () => { it('should fetch gas price', async () => {
const tx = this.manager.createTx(tx5) const tx = manager.createTx(tx4)
const receipt = await sendTx(tx)
receipt.type.should.equal(2) 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 fetch gas params', async () => { it('should bump gas price', async () => {
const tx = this.manager.createTx(tx4) const tx = manager.createTx(tx2)
await sendTx(tx)
})
it('should bump gas params', async () => { const receipt = await tx
const tx = this.manager.createTx(tx2) .send()
await sendTx(tx) .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 currentNonce = await this.manager._wallet.getTransactionCount('latest') const tx = manager.createTx(tx2)
const tx = this.manager.createTx(tx3)
setTimeout(() => tx.cancel(), 1000) setTimeout(() => tx.cancel(), 1000)
const receipt = await sendTx(tx)
const transaction = await this.manager._provider.getTransaction(receipt.transactionHash) const receipt = await tx
transaction.value.toNumber().should.equal(0) .send()
transaction.nonce.should.equal(currentNonce) .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 replace', async () => { it('should replace', async () => {
const currentNonce = await this.manager._wallet.getTransactionCount('latest') const tx = manager.createTx(tx2)
const tx = this.manager.createTx(tx3)
setTimeout(() => tx.replace(tx4), 1000)
const receipt = await sendTx(tx)
const transaction = await this.manager._provider.getTransaction(receipt.transactionHash)
receipt.to.should.equal(tx4.to)
transaction.nonce.should.equal(currentNonce)
})
it('should increase nonce', async () => { setTimeout(() => tx.replace(tx3), 1000)
const currentNonce = await this.manager._wallet.getTransactionCount('latest')
this.manager._nonce = currentNonce - 1
const tx = this.manager.createTx(tx4)
await sendTx(tx)
})
it('should disable eip-1559 transactions', async () => { const receipt = await tx
this.manager.config.ENABLE_EIP1559 = false .send()
const tx = this.manager.createTx(tx3) .on('transactionHash', hash => console.log('hash', hash))
const receipt = await sendTx(tx) .on('mined', receipt => console.log('Mined in block', receipt.blockNumber))
receipt.type.should.equal(0) .on('confirmations', confirmations => console.log('confirmations', confirmations))
this.manager.config.ENABLE_EIP1559 = true
})
it('should send multiple txs', async () => { console.log('receipt', receipt)
const genTx = value => ({
value,
to: '0x0039F22efB07A647557C7C5d17854CFD6D489eF3',
}) })
await Promise.all(Array.from({ length: 10 }).map(n => this.manager.createTx(genTx(n + 1)).send()))
}).timeout(600000)
}
describe('TxManager.default', () => {
before(async () => {
const {
network: { name, chainId },
options,
} = await getOptions()
options.chainId = chainId
console.log('default\n\n', 'network', { name, chainId }, '\n\n')
this.manager = new TxManager(options)
}) })
describe('#transaction', transactionTests)
})
describe('TxManager.EtherscanProvider', () => {
before(async () => {
const {
network: { name, chainId },
options,
} = await getOptions()
options.chainId = chainId
options.provider = new providers.EtherscanProvider(chainId, process.env.ETHERSCAN_API_KEY)
console.log('EtherscanProvider\n\n', 'network', { name, chainId }, '\n\n')
this.manager = new TxManager(options)
})
describe('#transaction', transactionTests)
})
describe('TxManager.InfuraProvider', () => {
before(async () => {
const {
network: { name, chainId },
options,
} = await getOptions()
options.chainId = chainId
options.provider = new providers.InfuraProvider(chainId, process.env.INFURA_API_KEY)
console.log('InfuraProvider\n\n', 'network', { name, chainId }, '\n\n')
this.manager = new TxManager(options)
})
describe('#transaction', transactionTests)
})
describe('TxManager.Web3Provider', () => {
before(async () => {
const {
network: { name, chainId },
options,
} = await getOptions()
options.chainId = chainId
options.provider = new providers.Web3Provider(new Web3.providers.HttpProvider(RPC_URL))
console.log('Web3Provider\n\n', 'network', { name, chainId }, '\n\n')
this.manager = new TxManager(options)
})
describe('#transaction', transactionTests)
}) })

3229
yarn.lock

File diff suppressed because it is too large Load Diff