Compare commits

..

30 Commits

Author SHA1 Message Date
6f09ac86b1 Use self-hosted dependecies & set package in Tornado scope 2023-09-13 22:56:32 -07:00
Serg
57aa950a2f
Typing (#4)
* type definitions

* remove Object.assign(), use BigNumber

* path custom ethers provider to constructor. test

* providers test

* using gas-price-oracle v0.5.0

* manager params provider type

* provider type fix

* update deps

* maxPriorityFee param, tests fix

* fix: cancel/replace

* fix: gasParams check

* fix: handleRpcError with web3 provider

Co-authored-by: Danil Kovtonyuk <danx.kov@gmail.com>
2022-07-14 15:39:17 +10:00
Danil Kovtonyuk
98c41bfdc8
fix: lint 2022-06-28 17:55:59 +10:00
Ayanami
f43f8088e5 Fixed TypeError bug 2022-06-28 17:55:33 +10:00
Ayanami
32a712e1db Customizable ethers provider 2022-06-28 17:45:07 +10:00
Danil Kovtonyuk
142ce883b4
bump version 2022-05-25 16:09:25 +10:00
Danil Kovtonyuk
cdce2334e4
revert estimate priority fee 2022-05-25 16:07:43 +10:00
Danil Kovtonyuk
09c24e12c9 bump gas price oracle 2022-05-23 20:28:36 +10:00
Danil Kovtonyuk
c1c6079745 fix: use eth_maxPriorityFeePerGas 2022-05-23 20:28:36 +10:00
Danil Kovtonyuk
eaaa54b7f9 fix: configure EIP-1559 feature 2022-05-23 20:28:36 +10:00
Danil Kovtonyuk
6adf89472e fix: estimate priority fee
- fix: predefined errors
2022-05-23 20:28:36 +10:00
Danil Kovtonyuk
b6ed5d2bc0
bump gas price oracle 2022-04-13 23:09:49 +10:00
Danil Kovtonyuk
b4ee1e5117
bump gas price oracle 2021-11-16 03:50:18 +10:00
Danil Kovtonyuk
56aa312829
bump gas price oracle 2021-11-16 01:56:29 +10:00
Danil Kovtonyuk
07dcb5d258
bump gas price oracle 2021-11-15 16:43:36 +10:00
Danil Kovtonyuk
4a17833462
bump gas price oracle 2021-10-19 00:39:35 +10:00
poma
c6652baa7c
inline _estimateFees() 2021-09-03 16:09:26 +03:00
Danil Kovtonyuk
a8e504054e fix: review 2021-09-03 22:58:13 +10:00
Danil Kovtonyuk
23e2e01172 feat: add EIP-1559 support 2021-09-03 22:58:13 +10:00
Danil Kovtonyuk
07752e0714
update gas price oracle 2021-08-25 22:35:05 +10:00
Danil Kovtonyuk
37f6faa42d
bump gas price oracle 2021-08-17 17:23:54 +10:00
Danil Kovtonyuk
9599788224 bump gas price oracle 2021-06-15 13:31:41 +03:00
Danil Kovtonyuk
72a665a19a add gasPriceOracleConfig 2021-06-03 16:16:31 +03:00
f6a4e93a23
fix mutex 2021-02-16 22:08:12 -08:00
af7c597af9
Merge branch 'master' of github.com:tornadocash/tx-manager 2021-02-16 21:48:51 -08:00
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
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
11 changed files with 3307 additions and 665 deletions

@ -1,3 +1,6 @@
# 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=...

@ -11,7 +11,7 @@
"SharedArrayBuffer": "readonly" "SharedArrayBuffer": "readonly"
}, },
"parserOptions": { "parserOptions": {
"ecmaVersion": 2018 "ecmaVersion": 2020
}, },
"rules": { "rules": {
"indent": [ "indent": [
@ -22,7 +22,13 @@
} }
], ],
"linebreak-style": ["error", "unix"], "linebreak-style": ["error", "unix"],
"quotes": ["error", "single", { "avoidEscape": true }], "quotes": [
"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 Normal file

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

124
index.d.ts vendored Normal file

@ -0,0 +1,124 @@
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>
}

@ -1,8 +1,12 @@
{ {
"name": "tx-manager", "name": "@tornado/tx-manager",
"version": "0.2.9", "version": "0.4.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",
@ -13,17 +17,15 @@
"keywords": [], "keywords": [],
"author": "Roman Semenov <semenov.roma@gmail.com>", "author": "Roman Semenov <semenov.roma@gmail.com>",
"license": "ISC", "license": "ISC",
"repository": { "repository": "https://git.tornado.ws/tornado-packages/tx-manager.git",
"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.0.17", "ethers": "^5.4.6",
"gas-price-oracle": "^0.2.2",
"web3-core-promievent": "^1.3.0" "web3-core-promievent": "^1.3.0"
}, },
"devDependencies": { "devDependencies": {
@ -34,6 +36,7 @@
"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"
} }
} }

@ -6,31 +6,37 @@ 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', /nonce too low/i,
'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.',
'replacement transaction underpriced', /transaction underpriced/,
'transaction underpriced', /fee too low/i,
/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
@ -57,22 +63,47 @@ 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) {
tx.gasLimit = await this._wallet.estimateGas(tx) const estimatedGasLimit = await this._estimateGas(tx)
tx.gasLimit = Math.floor(tx.gasLimit * this.config.GAS_LIMIT_MULTIPLIER) const gasLimit = estimatedGasLimit
tx.gasLimit = Math.min(tx.gasLimit, this.config.BLOCK_GAS_LIMIT) .mul(Math.floor(this.manager.config.GAS_LIMIT_MULTIPLIER * 100))
.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()
} }
@ -82,10 +113,9 @@ class Transaction {
cancel() { cancel() {
console.log('Canceling the transaction') console.log('Canceling the transaction')
return this.replace({ return this.replace({
from: this.address, from: this.manager.address,
to: this.address, to: this.manager.address,
value: 0, value: 0,
gasLimit: 21000,
}) })
} }
@ -96,16 +126,16 @@ class Transaction {
* @private * @private
*/ */
async _execute() { async _execute() {
await this.manager._mutex.acquire() const mutexRelease = await this.manager._mutex.acquire()
try { try {
await this._prepare() await this._prepare()
await this._send() 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 // 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 {
this.manager._mutex.release() mutexRelease()
} }
} }
@ -116,30 +146,40 @@ class Transaction {
* @private * @private
*/ */
async _prepare() { async _prepare() {
if (!this.config.BLOCK_GAS_LIMIT) { if (!this.manager.config.BLOCK_GAS_LIMIT) {
const lastBlock = await this._provider.getBlock('latest') const lastBlock = await this.manager._provider.getBlock('latest')
this.config.BLOCK_GAS_LIMIT = Math.floor(lastBlock.gasLimit.toNumber() * 0.95) this.manager.config.BLOCK_GAS_LIMIT = Math.floor(lastBlock.gasLimit.toNumber() * 0.95)
} }
if (!this.tx.gasLimit || this.config.ESTIMATE_GAS) { if (!this.manager._chainId) {
const gas = await this._wallet.estimateGas(this.tx) const net = await this.manager._provider.getNetwork()
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.config.GAS_LIMIT_MULTIPLIER) const gasLimit = Math.floor(gas * this.manager.config.GAS_LIMIT_MULTIPLIER)
this.tx.gasLimit = Math.min(gasLimit, this.config.BLOCK_GAS_LIMIT) this.tx.gasLimit = Math.min(gasLimit, this.manager.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)
}
} }
/** /**
@ -150,7 +190,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._wallet.signTransaction(this.tx) const signedTx = await this.manager._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)
@ -158,7 +198,7 @@ class Transaction {
try { try {
await this._broadcast(signedTx) await this._broadcast(signedTx)
} catch (e) { } catch (e) {
return this._handleSendError(e) return this._handleRpcError(e, '_send')
} }
this._emitter.emit('transactionHash', txHash) this._emitter.emit('transactionHash', txHash)
@ -176,7 +216,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._provider.getTransactionReceipt(this.currentTxHash) const receipt = await this.manager._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
@ -185,20 +225,20 @@ class Transaction {
continue continue
} }
const currentBlock = await this._provider.getBlockNumber() const currentBlock = await this.manager._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.manager.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) { if (this.manager.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.config.POLL_INTERVAL) await sleep(this.manager.config.POLL_INTERVAL)
continue continue
} }
@ -207,15 +247,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.config.GAS_BUMP_INTERVAL) { if (Date.now() - this.submitTimestamp >= this.manager.config.GAS_BUMP_INTERVAL) {
if (this._increaseGasPrice()) { if (this._increaseGasPrice()) {
console.log('Resubmitting with higher gas price') console.log('Resubmitting with higher gas params')
await this._send() await this._send()
continue continue
} }
} }
// Tx is still pending, keep waiting // Tx is still pending, keep waiting
await sleep(this.config.POLL_INTERVAL) await sleep(this.manager.config.POLL_INTERVAL)
continue continue
} }
@ -253,7 +293,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._provider.getTransactionReceipt(hash) const receipt = await this.manager._provider.getTransactionReceipt(hash)
if (receipt) { if (receipt) {
return receipt return receipt
} }
@ -264,11 +304,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
*/ */
_broadcast(rawTx) { async _broadcast(rawTx) {
const main = this._provider.sendTransaction(rawTx) const main = await this.manager._provider.sendTransaction(rawTx)
for (const node of this._broadcastNodes) { for (const node of this.manager._broadcastNodes) {
try { try {
new ethers.providers.JsonRpcProvider(node).sendTransaction(rawTx) await 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}`)
} }
@ -276,39 +316,48 @@ class Transaction {
return main return main
} }
_handleSendError(e) { _handleRpcError(e, method) {
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
} }
if (e.error && e.code === 'SERVER_ERROR') { // web3 provider not wrapping message
const message = e.error.message const message = e.error?.message || e.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.config.MAX_RETRIES) { if (this.retries <= this.manager.config.MAX_RETRIES) {
this.tx.nonce++ this.tx.nonce++
this.retries++ this.retries++
return this._send() return this[method]()
} }
} }
// 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(this.tx.gasPrice, 'gwei')} gwei is too low, increasing and retrying`, `Gas price ${formatUnits(
this.tx.gasPrice || this.tx.maxFeePerGas,
'gwei',
)} gwei is too low, increasing and retrying`,
) )
this._increaseGasPrice() if (this._increaseGasPrice()) {
return this._send() return this[method]()
} 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}`)
} }
@ -326,34 +375,48 @@ class Transaction {
} }
_increaseGasPrice() { _increaseGasPrice() {
const minGweiBump = parseUnits(this.config.MIN_GWEI_BUMP.toString(), 'gwei') const maxGasPrice = parseUnits(this.manager.config.MAX_GAS_PRICE.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)
const newGasPrice = max( if (oldGasPrice.gte(maxGasPrice)) {
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`)
return true } 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(
* Fetches gas price from the oracle oldMaxFeePerGas.mul(100 + this.manager.config.GAS_BUMP_PERCENTAGE).div(100),
* oldMaxFeePerGas.add(minGweiBump),
* @param {'instant'|'fast'|'normal'|'slow'} type )
* @returns {Promise<string>} A hex string representing gas price in wei const newMaxPriorityFeePerGas = max(
* @private oldMaxPriorityFeePerGas.mul(100 + this.manager.config.GAS_BUMP_PERCENTAGE).div(100),
*/ oldMaxPriorityFeePerGas.add(minGweiBump),
async _getGasPrice(type) { )
const gasPrices = await this._gasPriceOracle.gasPrices()
const result = gasPrices[type].toString() const maxFeePerGas = min(newMaxFeePerGas, maxGasPrice)
console.log(`${type} gas price is now ${result} gwei`)
return parseUnits(result, 'gwei').toHexString() 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
} }
/** /**
@ -363,7 +426,36 @@ class Transaction {
* @private * @private
*/ */
_getLastNonce() { _getLastNonce() {
return this._wallet.getTransactionCount('latest') return this.manager._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')
}
} }
} }

@ -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('gas-price-oracle') const { GasPriceOracle } = require('@tornado/gas-price-oracle')
const Transaction = require('./Transaction') const Transaction = require('./Transaction')
const defaultConfig = { const defaultConfig = {
@ -15,17 +15,20 @@ 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 = {} }) { constructor({ privateKey, rpcUrl, broadcastNodes = [], config = {}, gasPriceOracleConfig = {}, provider }) {
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 = new ethers.providers.JsonRpcProvider(rpcUrl) this._provider = 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 }) this._gasPriceOracle = new GasPriceOracle({ defaultRpc: rpcUrl, ...gasPriceOracleConfig })
this._mutex = new Mutex() this._mutex = new Mutex()
this._nonce = null this._nonce = null
} }

7
src/utils.d.ts vendored Normal file

@ -0,0 +1,7 @@
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

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

@ -1,105 +1,190 @@
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 Transaction = require('../src/Transaction') const Web3 = require('web3')
const { RPC_URL, PRIVATE_KEY } = process.env const { RPC_URL, PRIVATE_KEY } = process.env
describe('TxManager', () => { const tx1 = {
const manager = new TxManager({ value: 1,
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 * 15, GAS_BUMP_INTERVAL: 1000 * 20,
}, },
}) gasPriceOracleConfig: {
chainId: 1,
defaultRpc: RPC_URL,
},
provider: undefined,
}
const tx1 = { const getOptions = async () => {
value: 1, const provider = new providers.JsonRpcProvider(RPC_URL)
gasPrice: parseUnits('1', 'gwei').toHexString(), const network = await provider.getNetwork()
to: '0xA43Ce8Cc89Eff3AA5593c742fC56A30Ef2427CB0', const options = { ...defaultOptions }
} 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 fetch gas price', async () => { it('should work eip-1559 tx', async () => {
const tx = manager.createTx(tx4) const tx = this.manager.createTx(tx5)
const receipt = await sendTx(tx)
const receipt = await tx receipt.type.should.equal(2)
.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 () => { it('should fetch gas params', async () => {
const tx = manager.createTx(tx2) const tx = this.manager.createTx(tx4)
await sendTx(tx)
})
const receipt = await tx it('should bump gas params', async () => {
.send() const tx = this.manager.createTx(tx2)
.on('transactionHash', hash => console.log('hash', hash)) await sendTx(tx)
.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(tx2) const currentNonce = await this.manager._wallet.getTransactionCount('latest')
const tx = this.manager.createTx(tx3)
setTimeout(() => tx.cancel(), 1000) setTimeout(() => tx.cancel(), 1000)
const receipt = await sendTx(tx)
const receipt = await tx const transaction = await this.manager._provider.getTransaction(receipt.transactionHash)
.send() transaction.value.toNumber().should.equal(0)
.on('transactionHash', hash => console.log('hash', hash)) transaction.nonce.should.equal(currentNonce)
.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 tx = manager.createTx(tx2) const currentNonce = await this.manager._wallet.getTransactionCount('latest')
const tx = this.manager.createTx(tx3)
setTimeout(() => tx.replace(tx3), 1000) setTimeout(() => tx.replace(tx4), 1000)
const receipt = await sendTx(tx)
const receipt = await tx const transaction = await this.manager._provider.getTransaction(receipt.transactionHash)
.send() receipt.to.should.equal(tx4.to)
.on('transactionHash', hash => console.log('hash', hash)) transaction.nonce.should.equal(currentNonce)
.on('mined', receipt => console.log('Mined in block', receipt.blockNumber))
.on('confirmations', confirmations => console.log('confirmations', confirmations))
console.log('receipt', receipt)
}) })
it('should increase nonce', async () => {
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 () => {
this.manager.config.ENABLE_EIP1559 = false
const tx = this.manager.createTx(tx3)
const receipt = await sendTx(tx)
receipt.type.should.equal(0)
this.manager.config.ENABLE_EIP1559 = true
})
it('should send multiple txs', async () => {
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)
}) })

3227
yarn.lock

File diff suppressed because it is too large Load Diff