Compare commits
42 Commits
Author | SHA1 | Date | |
---|---|---|---|
6f09ac86b1 | |||
|
57aa950a2f | ||
|
98c41bfdc8 | ||
|
f43f8088e5 | ||
|
32a712e1db | ||
|
142ce883b4 | ||
|
cdce2334e4 | ||
|
09c24e12c9 | ||
|
c1c6079745 | ||
|
eaaa54b7f9 | ||
|
6adf89472e | ||
|
b6ed5d2bc0 | ||
|
b4ee1e5117 | ||
|
56aa312829 | ||
|
07dcb5d258 | ||
|
4a17833462 | ||
|
c6652baa7c | ||
|
a8e504054e | ||
|
23e2e01172 | ||
|
07752e0714 | ||
|
37f6faa42d | ||
|
9599788224 | ||
|
72a665a19a | ||
f6a4e93a23 | |||
af7c597af9 | |||
221dce3d73 | |||
|
8d4bab7fc2 | ||
|
af65d78be9 | ||
bc0b369095 | |||
|
e1620e15c1 | ||
|
414fb28a5e | ||
|
780df01b43 | ||
|
c5e4d76dc5 | ||
|
8cb2bb0fbe | ||
|
6bb265d3b9 | ||
|
b940fad5e0 | ||
|
1634e5fb16 | ||
|
62bcd2aa95 | ||
|
1eec6aa329 | ||
|
715ad59273 | ||
|
c6344b40d5 | ||
|
3b7d5ebd24 |
@ -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=...
|
||||
|
@ -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",
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -1 +1,2 @@
|
||||
node_modules
|
||||
.env
|
||||
|
1
.npmrc
Normal file
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
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>
|
||||
}
|
18
package.json
18
package.json
@ -1,8 +1,12 @@
|
||||
{
|
||||
"name": "tx-manager",
|
||||
"version": "0.1.1",
|
||||
"name": "@tornado/tx-manager",
|
||||
"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",
|
||||
@ -13,14 +17,15 @@
|
||||
"keywords": [],
|
||||
"author": "Roman Semenov <semenov.roma@gmail.com>",
|
||||
"license": "ISC",
|
||||
"repository": "https://git.tornado.ws/tornado-packages/tx-manager.git",
|
||||
"files": [
|
||||
"src/*"
|
||||
"src/*",
|
||||
"index.d.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"@tornado/gas-price-oracle": "^0.5.3",
|
||||
"async-mutex": "^0.2.4",
|
||||
"bn.js": "^5.1.3",
|
||||
"ethers": "^5.0.17",
|
||||
"gas-price-oracle": "^0.1.5",
|
||||
"ethers": "^5.4.6",
|
||||
"web3-core-promievent": "^1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -31,6 +36,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"
|
||||
}
|
||||
}
|
||||
|
@ -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,12 +198,11 @@ class Transaction {
|
||||
try {
|
||||
await this._broadcast(signedTx)
|
||||
} catch (e) {
|
||||
return this._handleSendError(e)
|
||||
return this._handleRpcError(e, '_send')
|
||||
}
|
||||
|
||||
this._emitter.emit('transactionHash', txHash)
|
||||
console.log(`Broadcasted transaction ${txHash}`)
|
||||
console.log(this.tx)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -168,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
|
||||
@ -177,17 +225,20 @@ class Transaction {
|
||||
continue
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@ -196,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
|
||||
}
|
||||
|
||||
@ -235,7 +286,6 @@ class Transaction {
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Mined. Start waiting for confirmations...')
|
||||
this._emitter.emit('mined', receipt)
|
||||
this.currentTxHash = receipt.transactionHash
|
||||
}
|
||||
@ -243,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
|
||||
}
|
||||
@ -254,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}`)
|
||||
}
|
||||
@ -266,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}`)
|
||||
@ -314,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(gasPrices[type], '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
|
||||
}
|
||||
|
||||
/**
|
||||
@ -351,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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
const ethers = require('ethers')
|
||||
const { Mutex } = require('async-mutex')
|
||||
const { GasPriceOracle } = require('gas-price-oracle')
|
||||
const { GasPriceOracle } = require('@tornado/gas-price-oracle')
|
||||
const Transaction = require('./Transaction')
|
||||
|
||||
const defaultConfig = {
|
||||
@ -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
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,14 +1,12 @@
|
||||
const BN = require('bn.js')
|
||||
const { BigNumber } = require('ethers')
|
||||
|
||||
/**
|
||||
* A promise that resolves after `ms` milliseconds
|
||||
*/
|
||||
const sleep = ms => new Promise(res => setTimeout(res, ms))
|
||||
|
||||
const max = (a, b) => BigNumber.from(BN.max(new BN(a.toString()), new BN(b.toString())).toString())
|
||||
const max = (a, b) => (BigNumber.from(a).gt(b) ? a : b)
|
||||
|
||||
const min = (a, b) => BigNumber.from(BN.min(new BN(a.toString()), new BN(b.toString())).toString())
|
||||
const min = (a, b) => (BigNumber.from(a).lt(b) ? a : b)
|
||||
|
||||
module.exports = {
|
||||
sleep,
|
||||
|
@ -1,88 +1,190 @@
|
||||
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',
|
||||
}
|
||||
|
||||
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 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.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)
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user