Compare commits

...

33 Commits

Author SHA1 Message Date
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
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
10 changed files with 3313 additions and 659 deletions

View File

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

View File

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

124
index.d.ts vendored Normal file
View 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 '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,8 +1,12 @@
{
"name": "tx-manager",
"version": "0.2.5",
"version": "0.4.9",
"description": "",
"main": "index.js",
"types": "index.d.ts",
"engines": {
"node": ">=14.0.0"
},
"scripts": {
"eslint": "eslint --ext .js --ignore-path .gitignore .",
"prettier:check": "prettier --check . --config .prettierrc",
@ -18,12 +22,13 @@
"url": "git://github.com/tornadocash/tx-manager.git"
},
"files": [
"src/*"
"src/*",
"index.d.ts"
],
"dependencies": {
"async-mutex": "^0.2.4",
"ethers": "^5.0.17",
"gas-price-oracle": "^0.2.0",
"ethers": "^5.4.6",
"gas-price-oracle": "^0.5.0",
"web3-core-promievent": "^1.3.0"
},
"devDependencies": {
@ -34,6 +39,7 @@
"eslint-plugin-prettier": "^3.1.4",
"mocha": "^8.1.3",
"prettier": "^2.1.2",
"web3": "^1.7.4",
"why-is-node-running": "^2.2.0"
}
}

View File

@ -4,31 +4,39 @@ const BigNumber = ethers.BigNumber
const PromiEvent = require('web3-core-promievent')
const { sleep, min, max } = require('./utils')
// prettier-ignore
const nonceErrors = [
'Transaction nonce is too low. Try incrementing the nonce.',
'nonce too low'
/nonce too low/i,
'nonce has already been used',
/OldNonce/,
'invalid transaction nonce',
]
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.',
'replacement 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./,
/FeeTooLow/,
/max fee per gas less than block base fee/,
]
// prettier-ignore
const sameTxErrors = [
'Transaction with the same hash was already imported.',
'already known',
'AlreadyKnown',
'Known transaction',
]
class Transaction {
constructor(tx, manager) {
Object.assign(this, manager)
this.manager = manager
this.tx = { ...tx }
this._promise = PromiEvent()
this._emitter = this._promise.eventEmitter
this.executed = false
this.replaced = false
this.retries = 0
this.currentTxHash = null
// store all submitted hashes to catch cases when an old tx is mined
@ -55,21 +63,47 @@ class Transaction {
*/
async replace(tx) {
// 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')
if (!this.executed) {
// Tx was not executed yet, just replace it
this.tx = { ...tx }
return
}
if (!tx.gasLimit) {
tx.gasLimit = await this._wallet.estimateGas(tx)
tx.gasLimit = Math.floor(tx.gasLimit * this.config.GAS_LIMIT_MULTIPLIER)
const estimatedGasLimit = await this._estimateGas(tx)
const gasLimit = estimatedGasLimit
.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.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._increaseGasPrice()
await this._prepare()
if (tx.gasPrice || tx.maxFeePerGas) {
this._increaseGasPrice()
}
this.replaced = true
await this._send()
}
@ -79,10 +113,9 @@ class Transaction {
cancel() {
console.log('Canceling the transaction')
return this.replace({
from: this.address,
to: this.address,
from: this.manager.address,
to: this.manager.address,
value: 0,
gasLimit: 21000,
})
}
@ -93,16 +126,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()
}
}
@ -113,24 +146,40 @@ class Transaction {
* @private
*/
async _prepare() {
if (!this.tx.gasLimit || this.config.ESTIMATE_GAS) {
const gas = await this._wallet.estimateGas(this.tx)
if (!this.manager.config.BLOCK_GAS_LIMIT) {
const lastBlock = await this.manager._provider.getBlock('latest')
this.manager.config.BLOCK_GAS_LIMIT = Math.floor(lastBlock.gasLimit.toNumber() * 0.95)
}
if (!this.manager._chainId) {
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) {
this.tx.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.manager.config.BLOCK_GAS_LIMIT)
}
}
if (!this.tx.gasPrice) {
this.tx.gasPrice = await this._getGasPrice('fast')
}
if (!this.manager._nonce) {
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
if (!this.tx.nonce) {
this.tx.nonce = this.manager._nonce
}
if (!this.tx.gasPrice && !this.tx.maxFeePerGas) {
const gasParams = await this._getGasParams()
this.tx = Object.assign(this.tx, gasParams)
}
this.tx.chainId = this.manager._chainId
}
/**
@ -141,7 +190,7 @@ class Transaction {
*/
async _send() {
// 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()
const txHash = ethers.utils.keccak256(signedTx)
this.hashes.push(txHash)
@ -149,7 +198,7 @@ class Transaction {
try {
await this._broadcast(signedTx)
} catch (e) {
return this._handleSendError(e)
return this._handleRpcError(e, '_send')
}
this._emitter.emit('transactionHash', txHash)
@ -167,7 +216,7 @@ class Transaction {
while (true) {
// We are already waiting on certain tx hash
if (this.currentTxHash) {
const receipt = await this._provider.getTransactionReceipt(this.currentTxHash)
const receipt = await this.manager._provider.getTransactionReceipt(this.currentTxHash)
if (!receipt) {
// We were waiting for some tx but it disappeared
@ -175,21 +224,21 @@ class Transaction {
this.currentTxHash = null
continue
}
if (Number(receipt.status) === 0) {
throw new Error('Transaction failed')
}
const currentBlock = await this._provider.getBlockNumber()
const currentBlock = await this.manager._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) {
if (confirmations >= this.manager.config.CONFIRMATIONS) {
// Tx is mined and has enough confirmations
if (this.manager.config.THROW_ON_REVERT && Number(receipt.status) === 0) {
throw new Error('EVM execution failed, so the transaction was reverted.')
}
return receipt
}
// 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
}
@ -198,15 +247,15 @@ class Transaction {
// todo optionally run estimateGas on each iteration and cancel the transaction if it fails
// 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()) {
console.log('Resubmitting with higher gas price')
console.log('Resubmitting with higher gas params')
await this._send()
continue
}
}
// Tx is still pending, keep waiting
await sleep(this.config.POLL_INTERVAL)
await sleep(this.manager.config.POLL_INTERVAL)
continue
}
@ -237,10 +286,6 @@ class Transaction {
}
}
if (Number(receipt.status) === 0) {
throw new Error('Transaction failed')
}
this._emitter.emit('mined', receipt)
this.currentTxHash = receipt.transactionHash
}
@ -248,7 +293,7 @@ class Transaction {
async _getReceipts() {
for (const hash of this.hashes.reverse()) {
const receipt = await this._provider.getTransactionReceipt(hash)
const receipt = await this.manager._provider.getTransactionReceipt(hash)
if (receipt) {
return receipt
}
@ -259,11 +304,11 @@ class Transaction {
/**
* Broadcasts tx to multiple nodes, waits for tx hash only on main node
*/
_broadcast(rawTx) {
const main = this._provider.sendTransaction(rawTx)
for (const node of this._broadcastNodes) {
async _broadcast(rawTx) {
const main = await this.manager._provider.sendTransaction(rawTx)
for (const node of this.manager._broadcastNodes) {
try {
new ethers.providers.JsonRpcProvider(node).sendTransaction(rawTx)
await new ethers.providers.JsonRpcProvider(node).sendTransaction(rawTx)
} catch (e) {
console.log(`Failed to send transaction to node ${node}: ${e}`)
}
@ -271,36 +316,47 @@ class Transaction {
return main
}
_handleSendError(e) {
console.log('Got error', e)
_handleRpcError(e, method) {
if (e.error?.error) {
// Sometimes ethers wraps known errors, unwrap it in this case
e = e.error
}
if (e.code === 'SERVER_ERROR' && e.error) {
const message = e.error.message
console.log('Error', e.error.code, e.error.message)
// web3 provider not wrapping message
const message = e.error?.message || e.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')
// nonce is too low, trying to increase and resubmit
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`)
if (this.retries <= this.manager.config.MAX_RETRIES) {
this.tx.nonce++
this.retries++
return this[method]()
}
}
// 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 || this.tx.maxFeePerGas,
'gwei',
)} gwei is too low, increasing and retrying`,
)
if (this._increaseGasPrice()) {
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)) {
console.log('Same transaction is already in mempool, skipping submit')
return // do nothing
}
throw new Error(`Send error: ${e}`)
@ -319,34 +375,48 @@ class Transaction {
}
_increaseGasPrice() {
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 = 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 = min(newGasPrice, maxGasPrice).toHexString()
console.log(`Increasing gas price to ${formatUnits(this.tx.gasPrice, 'gwei')} gwei`)
return true
}
const maxGasPrice = parseUnits(this.manager.config.MAX_GAS_PRICE.toString(), 'gwei')
const minGweiBump = parseUnits(this.manager.config.MIN_GWEI_BUMP.toString(), 'gwei')
/**
* 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()
if (this.tx.gasPrice) {
const oldGasPrice = BigNumber.from(this.tx.gasPrice)
if (oldGasPrice.gte(maxGasPrice)) {
console.log('Already at max gas price, not bumping')
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()
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
}
/**
@ -356,7 +426,36 @@ class Transaction {
* @private
*/
_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')
}
}
}

View File

@ -13,17 +13,22 @@ const defaultConfig = {
POLL_INTERVAL: 5000,
CONFIRMATIONS: 8,
ESTIMATE_GAS: true,
THROW_ON_REVERT: true,
BLOCK_GAS_LIMIT: null,
ENABLE_EIP1559: true,
DEFAULT_PRIORITY_FEE: 3,
BASE_FEE_RESERVE_PERCENTAGE: 50,
}
class TxManager {
constructor({ privateKey, rpcUrl, broadcastNodes = [], config = {} }) {
constructor({ privateKey, rpcUrl, broadcastNodes = [], config = {}, gasPriceOracleConfig = {}, provider }) {
this.config = Object.assign({ ...defaultConfig }, config)
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.address = this._wallet.address
this._broadcastNodes = broadcastNodes
this._gasPriceOracle = new GasPriceOracle({ defaultRpc: rpcUrl })
this._gasPriceOracle = new GasPriceOracle({ defaultRpc: rpcUrl, ...gasPriceOracleConfig })
this._mutex = new Mutex()
this._nonce = null
}

7
src/utils.d.ts vendored Normal file
View 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

View File

@ -1,11 +1,12 @@
const { BigNumber } = require('ethers')
/**
* A promise that resolves after `ms` milliseconds
*/
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 = {
sleep,

View File

@ -1,105 +1,204 @@
require('dotenv').config()
require('chai').should()
const { providers } = require('ethers')
const { parseUnits } = require('ethers').utils
const TxManager = require('../src/TxManager')
// const Transaction = require('../src/Transaction')
const Web3 = require('web3')
const { RPC_URL, PRIVATE_KEY } = process.env
describe('TxManager', () => {
const manager = new TxManager({
privateKey: PRIVATE_KEY,
rpcUrl: RPC_URL,
config: {
CONFIRMATIONS: 3,
GAS_BUMP_INTERVAL: 1000 * 15,
},
const tx1 = {
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,
rpcUrl: RPC_URL,
config: {
CONFIRMATIONS: 1,
GAS_BUMP_INTERVAL: 1000 * 20,
},
gasPriceOracleConfig: {
chainId: 1,
defaultRpc: RPC_URL,
},
provider: undefined,
}
const getOptions = async () => {
const provider = new providers.JsonRpcProvider(RPC_URL)
const network = await provider.getNetwork()
const options = { ...defaultOptions }
return { network, provider, options }
}
const sendTx = async tx => {
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)
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)
})
const tx1 = {
value: 1,
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)
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 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(tx2)
setTimeout(() => tx.cancel(), 1000)
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 replace', async () => {
const tx = manager.createTx(tx2)
setTimeout(() => tx.replace(tx3), 1000)
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 work eip-1559 tx', async () => {
const tx = this.manager.createTx(tx5)
const receipt = await sendTx(tx)
receipt.type.should.equal(2)
})
it('should fetch gas params', async () => {
const tx = this.manager.createTx(tx4)
await sendTx(tx)
})
it('should bump gas params', async () => {
const tx = this.manager.createTx(tx2)
await sendTx(tx)
})
it('should cancel', async () => {
const currentNonce = await this.manager._wallet.getTransactionCount('latest')
const tx = this.manager.createTx(tx3)
setTimeout(() => tx.cancel(), 1000)
const receipt = await sendTx(tx)
const transaction = await this.manager._provider.getTransaction(receipt.transactionHash)
transaction.value.toNumber().should.equal(0)
transaction.nonce.should.equal(currentNonce)
})
it('should replace', async () => {
const currentNonce = await this.manager._wallet.getTransactionCount('latest')
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 () => {
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.AlchemyProvider', () => {
before(async () => {
const {
network: { name, chainId },
options,
} = await getOptions()
options.chainId = chainId
options.provider = new providers.AlchemyProvider(chainId, process.env.ALCHEMY_API_KEY)
console.log('AlchemyProvider\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)
})

3210
yarn.lock

File diff suppressed because it is too large Load Diff