Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Automatic transaction repricing / replacement #847

Merged
merged 25 commits into from
Nov 23, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
7a79407
feat: add `silent` kwarg for tx.replace
iamdefinitelyahuman Nov 17, 2020
04386bc
feat: brownie.network.gas
iamdefinitelyahuman Nov 17, 2020
c6997ad
feat: gas strategy abc's
iamdefinitelyahuman Nov 17, 2020
aea7e11
feat: gas strategy queue and loop
iamdefinitelyahuman Nov 17, 2020
dd6b2b0
feat: implement gas strategy within account transaction logic
iamdefinitelyahuman Nov 17, 2020
4df56d0
feat: gasnow strategy
iamdefinitelyahuman Nov 17, 2020
8caaf1a
fix: handle ValueError in replacement loop
iamdefinitelyahuman Nov 17, 2020
9c849d7
feat: return confirmed when blocking tx drops
iamdefinitelyahuman Nov 17, 2020
3b0e5b7
refactor: handle dropping tx's in transaction instead of history
iamdefinitelyahuman Nov 17, 2020
98decb0
feat: ensure returned tx is the confirmed one
iamdefinitelyahuman Nov 17, 2020
e0fc0ce
feat: allow use of gas strategy in network.gas_price
iamdefinitelyahuman Nov 18, 2020
8659433
fix: include gas strategy on deployment tx's
iamdefinitelyahuman Nov 18, 2020
3a6b518
refactor: split `get_gas_price` and `update_gas_price`
iamdefinitelyahuman Nov 18, 2020
bb54e36
docs: add natspec for strategy classes and abcs
iamdefinitelyahuman Nov 18, 2020
61ce105
fix: improve console output for blocking replaced tx's
iamdefinitelyahuman Nov 18, 2020
18dc311
feat: silence pending tx output on keyboard interrupt
iamdefinitelyahuman Nov 18, 2020
9e04604
fix: updates to gasnow strategies
iamdefinitelyahuman Nov 18, 2020
617699b
style: always return int from gas strategy
iamdefinitelyahuman Nov 19, 2020
196fd7e
refactor: use generator functions for scaling strategies, remove stra…
iamdefinitelyahuman Nov 19, 2020
a335cf0
feat: linear and exponential scaling strategies
iamdefinitelyahuman Nov 19, 2020
36db8f4
feat: graphql gas strategy
iamdefinitelyahuman Nov 20, 2020
b7a36bc
docs: add documentation for gas strategies
iamdefinitelyahuman Nov 19, 2020
6ea0c08
test: basic gas strategy tests
iamdefinitelyahuman Nov 19, 2020
888fb2d
fix: raise ValueError if exc is None
iamdefinitelyahuman Nov 22, 2020
9524cd6
refactor: adjust logic for handling failed gasnow api queries
iamdefinitelyahuman Nov 23, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions brownie/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ def _with_attr(self, **kwargs) -> "VirtualMachineError":
return self


class TransactionError(Exception):
pass


class EventLookupError(LookupError):
pass

Expand Down
98 changes: 81 additions & 17 deletions brownie/network/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
import json
import threading
import time
from collections.abc import Iterator
from getpass import getpass
from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional, Tuple, Union
from typing import Any, Dict, List, Optional, Tuple, Union

import eth_account
import eth_keys
Expand All @@ -19,11 +20,13 @@
from brownie.exceptions import (
ContractNotFound,
IncompatibleEVMVersion,
TransactionError,
UnknownAccount,
VirtualMachineError,
)
from brownie.utils import color

from .gas.bases import GasABC
from .rpc import Rpc
from .state import Chain, TxHistory, _revert_register
from .transaction import TransactionReceipt
Expand Down Expand Up @@ -368,11 +371,29 @@ def _gas_limit(

return Wei(gas_limit)

def _gas_price(self) -> Wei:
gas_price = CONFIG.active_network["settings"]["gas_price"]
def _gas_price(self, gas_price: Any = None) -> Tuple[Wei, Optional[GasABC], Optional[Iterator]]:
# returns the gas price, gas strategy object, and active gas strategy iterator
if gas_price is None:
gas_price = CONFIG.active_network["settings"]["gas_price"]

if isinstance(gas_price, GasABC):
value = gas_price.get_gas_price()
if isinstance(value, Iterator):
# if `get_gas_price` returns an interator, this is a gas strategy
# intended for rebroadcasting. we need to retain both the strategy
# object and the active gas price iterator
return Wei(next(value)), gas_price, value
else:
# for simple strategies, we can simply use the generated gas price
return Wei(value), None, None

if isinstance(gas_price, Wei):
return gas_price, None, None

if isinstance(gas_price, bool) or gas_price in (None, "auto"):
return web3.eth.generateGasPrice()
return Wei(gas_price)
return web3.eth.generateGasPrice(), None, None

return Wei(gas_price), None, None

def _check_for_revert(self, tx: Dict) -> None:
try:
Expand Down Expand Up @@ -428,7 +449,7 @@ def deploy(
silent = bool(CONFIG.mode == "test" or CONFIG.argv["silent"])
with self._lock:
try:
gas_price = Wei(gas_price) if gas_price is not None else self._gas_price()
gas_price, gas_strategy, gas_iter = self._gas_price(gas_price)
gas_limit = Wei(gas_limit) or self._gas_limit(
None, amount, gas_price, gas_buffer, data
)
Expand Down Expand Up @@ -460,11 +481,8 @@ def deploy(
name=contract._name + ".constructor",
revert_data=revert_data,
)
# add the TxHistory before waiting for confirmation, this way the tx
# object is available if the user CTRL-C to stop waiting in the console
history._add_tx(receipt)
if required_confs > 0:
receipt._confirmed.wait()

receipt = self._await_confirmation(receipt, required_confs, gas_strategy, gas_iter)

add_thread = threading.Thread(target=contract._add_from_tx, args=(receipt,), daemon=True)
add_thread.start()
Expand Down Expand Up @@ -579,7 +597,7 @@ def transfer(
if silent is None:
silent = bool(CONFIG.mode == "test" or CONFIG.argv["silent"])
with self._lock:
gas_price = Wei(gas_price) if gas_price is not None else self._gas_price()
gas_price, gas_strategy, gas_iter = self._gas_price(gas_price)
gas_limit = Wei(gas_limit) or self._gas_limit(to, amount, gas_price, gas_buffer, data)
tx = {
"from": self.address,
Expand Down Expand Up @@ -609,11 +627,8 @@ def transfer(
silent=silent,
revert_data=revert_data,
)
# add the TxHistory before waiting for confirmation, this way the tx
# object is available if the user CTRL-C to stop waiting in the console
history._add_tx(receipt)
if required_confs > 0:
receipt._confirmed.wait()

receipt = self._await_confirmation(receipt, required_confs, gas_strategy, gas_iter)

if rpc.is_active():
undo_thread = threading.Thread(
Expand All @@ -627,9 +642,58 @@ def transfer(
daemon=True,
)
undo_thread.start()

receipt._raise_if_reverted(exc)
return receipt

def _await_confirmation(
self,
receipt: TransactionReceipt,
required_confs: int,
gas_strategy: Optional[GasABC],
gas_iter: Optional[Iterator],
) -> TransactionReceipt:
# add to TxHistory before waiting for confirmation, this way the tx
# object is available if the user exits blocking via keyboard interrupt
history._add_tx(receipt)

if gas_strategy is not None:
gas_strategy.run(receipt, gas_iter) # type: ignore

if required_confs == 0:
receipt._silent = True
return receipt

try:
receipt._confirmed.wait()
except KeyboardInterrupt:
# set related transactions as silent
receipt._silent = True
for receipt in history.filter(
sender=self, nonce=receipt.nonce, key=lambda k: k.status != -2
):
receipt._silent = True
raise

if receipt.status != -2:
return receipt

# if transaction was dropped (status -2), find and return the tx that confirmed
replacements = history.filter(
sender=self, nonce=receipt.nonce, key=lambda k: k.status != -2
)
while True:
if not replacements:
raise TransactionError(f"Tx dropped without known replacement: {receipt.txid}")
if len(replacements) > 1:
# in case we have multiple tx's where the status is still unresolved
replacements = [i for i in replacements if i.status != 2]
time.sleep(0.5)
else:
receipt = replacements[0]
receipt._await_confirmation(required_confs=required_confs)
return receipt


class Account(_PrivateKeyAccount):

Expand Down
Empty file added brownie/network/gas/__init__.py
Empty file.
164 changes: 164 additions & 0 deletions brownie/network/gas/bases.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import inspect
import threading
import time
from abc import ABC, abstractmethod
from typing import Any, Generator, Iterator, Union

from brownie.network.web3 import web3


class GasABC(ABC):
"""
Base ABC for all gas strategies.

This class should not be directly subclassed from. Instead, use
`SimpleGasStrategy`, `BlockGasStrategy` or `TimeGasStrategy`.
"""

@abstractmethod
def get_gas_price(self) -> Union[Generator[int, None, None], int]:
raise NotImplementedError


class ScalingGasABC(GasABC):
"""
Base ABC for scaling gas strategies.

This class should not be directly subclassed from.
Instead, use `BlockGasStrategy` or `TimeGasStrategy`.
"""

duration: int

def __new__(cls, *args: Any, **kwargs: Any) -> object:
obj = super().__new__(cls)
if not inspect.isgeneratorfunction(cls.get_gas_price):
raise TypeError("Scaling strategy must implement get_gas_price as a generator function")
return obj

@abstractmethod
def interval(self) -> int:
"""
Return "now" as it relates to the scaling strategy.

This can be e.g. the current time or block height. It is used in combination
with `duration` to determine when to rebroadcast a transaction.
"""
raise NotImplementedError

def _loop(self, receipt: Any, gas_iter: Iterator) -> None:
while web3.eth.getTransactionCount(str(receipt.sender)) < receipt.nonce:
# do not run scaling strategy while prior tx's are still pending
time.sleep(5)

latest_interval = self.interval()
while True:
if web3.eth.getTransactionCount(str(receipt.sender)) > receipt.nonce:
break

if self.interval() - latest_interval >= self.duration:
gas_price = next(gas_iter)
if gas_price >= int(receipt.gas_price * 1.1):
try:
receipt = receipt.replace(gas_price=gas_price)
latest_interval = self.interval()
except ValueError:
pass
time.sleep(2)

def run(self, receipt: Any, gas_iter: Iterator) -> None:
thread = threading.Thread(
target=self._loop,
args=(receipt, gas_iter),
daemon=True,
name=f"Gas strategy {receipt.txid}",
)
thread.start()


class SimpleGasStrategy(GasABC):
"""
Abstract base class for simple gas strategies.

Simple gas strategies are called once to provide a gas price
at the time a transaction is broadcasted. Transactions using simple
gas strategies are not automatically rebroadcasted.

Subclass from this ABC to implement your own simple gas strategy.
"""

@abstractmethod
def get_gas_price(self) -> int:
"""
Return the initial gas price for a transaction.

Returns
-------
int
Gas price, given as an integer in wei.
"""
raise NotImplementedError


class BlockGasStrategy(ScalingGasABC):
"""
Abstract base class for block gas strategies.

Block gas strategies are called every `block_duration` blocks and
can be used to automatically rebroadcast a pending transaction with
a higher gas price.

Subclass from this ABC to implement your own block gas strategy.
"""

duration = 2

def __init__(self, block_duration: int = 2) -> None:
self.duration = block_duration

def interval(self) -> int:
return web3.eth.blockNumber

@abstractmethod
def get_gas_price(self) -> Generator[int, None, None]:
"""
Generator function to yield gas prices for a transaction.

Returns
-------
int
Gas price, given as an integer in wei.
"""
raise NotImplementedError


class TimeGasStrategy(ScalingGasABC):
"""
Abstract base class for time gas strategies.

Time gas strategies are called every `time_duration` seconds and
can be used to automatically rebroadcast a pending transaction with
a higher gas price.

Subclass from this ABC to implement your own time gas strategy.
"""

duration = 30

def __init__(self, time_duration: int = 30) -> None:
self.duration = time_duration

def interval(self) -> int:
return int(time.time())

@abstractmethod
def get_gas_price(self) -> Generator[int, None, None]:
"""
Generator function to yield gas prices for a transaction.

Returns
-------
int
Gas price, given as an integer in wei.
"""
raise NotImplementedError
Loading