diff --git a/brownie/network/account.py b/brownie/network/account.py index 12f995c84..1e1bf69f2 100644 --- a/brownie/network/account.py +++ b/brownie/network/account.py @@ -451,10 +451,16 @@ def deploy( self, silent=silent, required_confs=required_confs, + is_blocking=False, 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() + add_thread = threading.Thread(target=contract._add_from_tx, args=(receipt,), daemon=True) add_thread.start() @@ -585,9 +591,19 @@ def transfer( revert_data = (exc.revert_msg, exc.pc, exc.revert_type) receipt = TransactionReceipt( - txid, self, required_confs=required_confs, silent=silent, revert_data=revert_data + txid, + self, + required_confs=required_confs, + is_blocking=False, + 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() + if rpc.is_active(): undo_thread = threading.Thread( target=Chain()._add_to_undo_buffer, diff --git a/brownie/network/event.py b/brownie/network/event.py index bfee75b58..81b5e1568 100644 --- a/brownie/network/event.py +++ b/brownie/network/event.py @@ -19,11 +19,14 @@ class EventDict: Dict/list hybrid container, base class for all events fired in a transaction. """ - def __init__(self, events: List) -> None: + def __init__(self, events: Optional[List] = None) -> None: """Instantiates the class. Args: events: event data as supplied by eth_event.decode_logs or eth_event.decode_trace""" + if events is None: + events = [] + self._ordered = [ _EventItem( i["name"], @@ -208,9 +211,9 @@ def _add_deployment_topics(address: str, abi: List) -> None: _deployment_topics[address] = eth_event.get_topic_map(abi) -def _decode_logs(logs: List) -> Union["EventDict", List[None]]: +def _decode_logs(logs: List) -> EventDict: if not logs: - return [] + return EventDict() idx = 0 events: List = [] @@ -237,9 +240,9 @@ def _decode_logs(logs: List) -> Union["EventDict", List[None]]: return EventDict(events) -def _decode_trace(trace: Sequence, initial_address: str) -> Union["EventDict", List[None]]: +def _decode_trace(trace: Sequence, initial_address: str) -> EventDict: if not trace: - return [] + return EventDict() events = eth_event.decode_traceTransaction( trace, _topics, allow_undecoded=True, initial_address=initial_address diff --git a/brownie/network/state.py b/brownie/network/state.py index 868af1ec5..75e7c2602 100644 --- a/brownie/network/state.py +++ b/brownie/network/state.py @@ -45,6 +45,13 @@ def __repr__(self) -> str: return str(self._list) return super().__repr__() + def __getattribute__(self, name: str) -> Any: + # filter dropped transactions prior to attribute access + items = super().__getattribute__("_list") + items = [i for i in items if i.status != -2] + setattr(self, "_list", items) + return super().__getattribute__(name) + def __bool__(self) -> bool: return bool(self._list) @@ -68,6 +75,14 @@ def _revert(self, height: int) -> None: def _add_tx(self, tx: TransactionReceipt) -> None: self._list.append(tx) + confirm_thread = threading.Thread(target=self._await_confirm, args=(tx,), daemon=True) + confirm_thread.start() + + def _await_confirm(self, tx: TransactionReceipt) -> None: + # in case of multiple tx's with the same nonce, remove the dropped tx's upon confirmation + tx._confirmed.wait() + for dropped_tx in self.filter(sender=tx.sender, nonce=tx.nonce, key=lambda k: k != tx): + self._list.remove(dropped_tx) def clear(self) -> None: self._list.clear() @@ -76,7 +91,9 @@ def copy(self) -> List: """Returns a shallow copy of the object as a list""" return self._list.copy() - def filter(self, key: Optional[Callable] = None, **kwargs: Dict) -> List: + def filter( + self, key: Optional[Callable] = None, **kwargs: Optional[Any] + ) -> List[TransactionReceipt]: """ Return a filtered list of transactions. diff --git a/brownie/network/transaction.py b/brownie/network/transaction.py index 58955b273..1644144e4 100644 --- a/brownie/network/transaction.py +++ b/brownie/network/transaction.py @@ -5,6 +5,7 @@ import threading import time from collections import OrderedDict +from enum import IntEnum from hashlib import sha1 from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union @@ -13,7 +14,7 @@ import requests from eth_abi import decode_abi from hexbytes import HexBytes -from web3.exceptions import TransactionNotFound +from web3.exceptions import TimeExhausted, TransactionNotFound from brownie._config import CONFIG from brownie.convert import EthAddress, Wei @@ -26,7 +27,7 @@ from brownie.utils.output import build_tree from . import state -from .event import _decode_logs, _decode_trace +from .event import EventDict, _decode_logs, _decode_trace from .web3 import web3 @@ -35,7 +36,7 @@ def trace_property(fn: Callable) -> Any: @property # type: ignore def wrapper(self: "TransactionReceipt") -> Any: - if self.status == -1: + if self.status < 0: return None if self._trace_exc is not None: raise self._trace_exc @@ -58,6 +59,13 @@ def wrapper(self: "TransactionReceipt", *args: Any, **kwargs: Any) -> Any: return wrapper +class Status(IntEnum): + Dropped = -2 + Pending = -1 + Reverted = 0 + Confirmed = 1 + + class TransactionReceipt: """Attributes and methods relating to a broadcasted transaction. @@ -105,7 +113,7 @@ class TransactionReceipt: logs = None nonce = None sender = None - txid = None + txid: str txindex = None def __init__( @@ -114,6 +122,7 @@ def __init__( sender: Any = None, silent: bool = True, required_confs: int = 1, + is_blocking: bool = True, name: str = "", revert_data: Optional[Tuple] = None, ) -> None: @@ -123,6 +132,8 @@ def __init__( txid: hexstring transaction ID sender: sender as a hex string or Account object required_confs: the number of required confirmations before processing the receipt + is_blocking: if True, creating the object is a blocking action until the required + confirmations are received silent: toggles console verbosity (default True) name: contract function being called revert_data: (revert string, program counter, revert type) @@ -135,24 +146,24 @@ def __init__( print(f"Transaction sent: {color('bright blue')}{txid}{color}") # internal attributes - self._trace_exc = None - self._trace_origin = None - self._raw_trace = None - self._trace = None self._call_cost = 0 - self._events = None - self._return_value = None - self._revert_msg = None - self._modified_state = None - self._new_contracts = None - self._internal_transfers = None - self._subcalls: Optional[List[Dict]] = None self._confirmed = threading.Event() + self._trace_exc: Optional[Exception] = None + self._trace_origin: Optional[str] = None + self._raw_trace: Optional[List] = None + self._trace: Optional[List] = None + self._events: Optional[EventDict] = None + self._return_value: Any = None + self._revert_msg: Optional[str] = None + self._modified_state: Optional[bool] = None + self._new_contracts: Optional[List] = None + self._internal_transfers: Optional[List[Dict]] = None + self._subcalls: Optional[List[Dict]] = None # attributes that can be set immediately self.sender = sender - self.status = -1 - self.txid = txid + self.status = Status(-1) + self.txid = str(txid) self.contract_name = None self.fn_name = name @@ -164,25 +175,17 @@ def __init__( if self._revert_msg is None and revert_type not in ("revert", "invalid_opcode"): self._revert_msg = revert_type - self._await_transaction(required_confs) - - # if coverage evaluation is active, evaluate the trace - if ( - CONFIG.argv["coverage"] - and not coverage._check_cached(self.coverage_hash) - and self.trace - ): - self._expand_trace() + self._await_transaction(required_confs, is_blocking) def __repr__(self) -> str: - c = {-1: "bright yellow", 0: "bright red", 1: None} - return f"" + color_str = {-2: "dark white", -1: "bright yellow", 0: "bright red", 1: ""}[self.status] + return f"" def __hash__(self) -> int: return hash(self.txid) @trace_property - def events(self) -> Optional[List]: + def events(self) -> Optional[EventDict]: if not self.status: self._get_trace() return self._events @@ -243,7 +246,7 @@ def trace(self) -> Optional[List]: @property def timestamp(self) -> Optional[int]: - if self.status == -1: + if self.status < 0: return None return web3.eth.getBlock(self.block_number)["timestamp"] @@ -253,7 +256,51 @@ def confirmations(self) -> int: return 0 return web3.eth.blockNumber - self.block_number + 1 + def replace( + self, increment: Optional[float] = None, gas_price: Optional[Wei] = None, + ) -> "TransactionReceipt": + """ + Rebroadcast this transaction with a higher gas price. + + Exactly one of `increment` and `gas_price` must be given. + + Arguments + --------- + increment : float, optional + Multiplier applied to the gas price of this transaction in order + to determine the new gas price + gas_price: Wei, optional + Absolute gas price to use in the replacement transaction + + Returns + ------- + TransactionReceipt + New transaction object + """ + if increment is None and gas_price is None: + raise ValueError("Must give one of `increment` or `gas_price`") + if gas_price is not None and increment is not None: + raise ValueError("Cannot set `increment` and `gas_price` together") + if self.status > -1: + raise ValueError("Transaction has already confirmed") + + if increment is not None: + gas_price = Wei(self.gas_price * 1.1) + + return self.sender.transfer( # type: ignore + self.receiver, + self.value, + gas_limit=self.gas_limit, + gas_price=Wei(gas_price), + data=self.input, + nonce=self.nonce, + required_confs=0, + silent=self._silent, + ) + def wait(self, required_confs: int) -> None: + if required_confs < 1: + return if self.confirmations > required_confs: print(f"This transaction already has {self.confirmations} confirmations.") return @@ -263,7 +310,11 @@ def wait(self, required_confs: int) -> None: tx: Dict = web3.eth.getTransaction(self.txid) break except TransactionNotFound: - time.sleep(0.5) + if self.sender.nonce > self.nonce: # type: ignore + self.status = Status(-2) + print("This transaction was replaced.") + return + time.sleep(1) self._await_confirmation(tx, required_confs) @@ -281,18 +332,21 @@ def _raise_if_reverted(self, exc: Any) -> None: source = self._error_string(1) raise exc._with_attr(source=source, revert_msg=self._revert_msg) - def _await_transaction(self, required_confs: int = 1) -> None: + def _await_transaction(self, required_confs: int, is_blocking: bool) -> None: # await tx showing in mempool while True: try: - tx: Dict = web3.eth.getTransaction(self.txid) + tx: Dict = web3.eth.getTransaction(HexBytes(self.txid)) break - except TransactionNotFound: + except (TransactionNotFound, ValueError): if self.sender is None: # if sender was not explicitly set, this transaction was # not broadcasted locally and so likely doesn't exist raise - time.sleep(0.5) + if self.nonce is not None and self.sender.nonce > self.nonce: + self.status = Status(-2) + return + time.sleep(1) self._set_from_tx(tx) if not self._silent: @@ -307,7 +361,7 @@ def _await_transaction(self, required_confs: int = 1) -> None: target=self._await_confirmation, args=(tx, required_confs), daemon=True ) confirm_thread.start() - if required_confs > 0: + if is_blocking and required_confs > 0: confirm_thread.join() def _await_confirmation(self, tx: Dict, required_confs: int = 1) -> None: @@ -322,7 +376,19 @@ def _await_confirmation(self, tx: Dict, required_confs: int = 1) -> None: sys.stdout.flush() # await first confirmation - receipt = web3.eth.waitForTransactionReceipt(self.txid, timeout=None, poll_latency=0.5) + while True: + # if sender nonce is greater than tx nonce, the tx should be confirmed + expect_confirmed = bool(self.sender.nonce > self.nonce) # type: ignore + try: + receipt = web3.eth.waitForTransactionReceipt( + HexBytes(self.txid), timeout=30, poll_latency=1 + ) + break + except TimeExhausted: + if expect_confirmed: + # if we expected confirmation based on the nonce, tx likely dropped + self.status = Status(-2) + return self.block_number = receipt["blockNumber"] # wait for more confirmations if required and handle uncle blocks @@ -353,9 +419,16 @@ def _await_confirmation(self, tx: Dict, required_confs: int = 1) -> None: time.sleep(1) self._set_from_receipt(receipt) - self._confirmed.set() + # if coverage evaluation is active, evaluate the trace + if ( + CONFIG.argv["coverage"] + and not coverage._check_cached(self.coverage_hash) + and self.trace + ): + self._expand_trace() if not self._silent and required_confs > 0: print(self._confirm_output()) + self._confirmed.set() def _set_from_tx(self, tx: Dict) -> None: if not self.sender: @@ -379,7 +452,7 @@ def _set_from_receipt(self, receipt: Dict) -> None: self.txindex = receipt["transactionIndex"] self.gas_used = receipt["gasUsed"] self.logs = receipt["logs"] - self.status = receipt["status"] + self.status = Status(receipt["status"]) self.contract_address = receipt["contractAddress"] if self.contract_address and not self.contract_name: @@ -387,7 +460,7 @@ def _set_from_receipt(self, receipt: Dict) -> None: base = ( f"{self.nonce}{self.block_number}{self.sender}{self.receiver}" - f"{self.value}{self.input}{self.status}{self.gas_used}{self.txindex}" + f"{self.value}{self.input}{int(self.status)}{self.gas_used}{self.txindex}" ) self.coverage_hash = sha1(base.encode()).hexdigest() diff --git a/docs/api-network.rst b/docs/api-network.rst index ceda31ff2..5d82d8a41 100644 --- a/docs/api-network.rst +++ b/docs/api-network.rst @@ -2219,7 +2219,12 @@ TransactionReceipt Attributes .. py:attribute:: TransactionReceipt.status - The status of the transaction: -1 for pending, 0 for failed, 1 for success. + An :class:`IntEnum ` object representing the status of the transaction: + + * ``1``: Successful + * ``0``: Reverted + * ``-1``: Pending + * ``-2``: Dropped .. code-block:: python @@ -2315,6 +2320,29 @@ TransactionReceipt Attributes TransactionReceipt Methods ************************** +.. py:method:: TransactionReceipt.replace(increment=None, gas_price=None) + + Broadcast an identical transaction with the same nonce and a higher gas price. + + Exactly one of the following arguments must be provided: + + * ``increment``: Multiplier applied to the gas price of the current transaction in order to determine a new gas price + * ``gas_price``: Absolute gas price to use in the replacement transaction + + Returns a :func:`TransactionReceipt ` object. + + .. code-block:: python + + >>> tx = accounts[0].transfer(accounts[1], 100, required_confs=0, gas_price="1 gwei") + Transaction sent: 0xc1aab54599d7875fc1fe8d3e375abb0f490cbb80d5b7f48cedaa95fa726f29be + Gas price: 13.0 gwei Gas limit: 21000 Nonce: 3 + + + >>> tx.replace(1.1) + Transaction sent: 0x9a525e42b326c3cd57e889ad8c5b29c88108227a35f9763af33dccd522375212 + Gas price: 14.3 gwei Gas limit: 21000 Nonce: 3 + + .. py:classmethod:: TransactionReceipt.info() Displays verbose information about the transaction, including event logs and the error string if a transaction reverts. diff --git a/docs/core-accounts.rst b/docs/core-accounts.rst index fccc2c69c..f8eb03bca 100644 --- a/docs/core-accounts.rst +++ b/docs/core-accounts.rst @@ -89,3 +89,22 @@ Additionally, setting ``silent = True`` suppresses the console output. [1, -1, -1] These transactions are initially pending (``status == -1``) and appear yellow in the console. + +Replacing Transactions +====================== + +The :func:`TransactionReceipt.replace ` method can be used to replace underpriced transactions while they are still pending: + +.. code-block:: python + + >>> tx = accounts[0].transfer(accounts[1], 100, required_confs=0, gas_price="1 gwei") + Transaction sent: 0xc1aab54599d7875fc1fe8d3e375abb0f490cbb80d5b7f48cedaa95fa726f29be + Gas price: 13.0 gwei Gas limit: 21000 Nonce: 3 + + + >>> tx.replace(1.1) + Transaction sent: 0x9a525e42b326c3cd57e889ad8c5b29c88108227a35f9763af33dccd522375212 + Gas price: 14.3 gwei Gas limit: 21000 Nonce: 3 + + +All pending transactions are available within the :func:`history ` object. As soon as one transaction confirms, the remaining dropped transactions are removed. See the documentation on :ref:`accessing transaction history ` for more info. diff --git a/docs/core-chain.rst b/docs/core-chain.rst index 3cd204be1..b89808b63 100644 --- a/docs/core-chain.rst +++ b/docs/core-chain.rst @@ -53,6 +53,8 @@ The :func:`Chain ` object, available as ``chain``, Accessing Transaction Data ========================== +.. _core-chain-history: + Local Transaction History ------------------------- diff --git a/tests/network/transaction/test_confirmation.py b/tests/network/transaction/test_confirmation.py index 6fde6c9ae..83f8769c9 100644 --- a/tests/network/transaction/test_confirmation.py +++ b/tests/network/transaction/test_confirmation.py @@ -4,31 +4,31 @@ def test_await_conf_simple_xfer(accounts): tx = accounts[0].transfer(accounts[1], "1 ether") assert tx.status == 1 - tx._await_transaction() + tx._await_transaction(1, True) def test_await_conf_successful_contract_call(accounts, tester): tx = tester.revertStrings(6, {"from": accounts[1]}) assert tx.status == 1 - tx._await_transaction() + tx._await_transaction(1, True) def test_await_conf_failed_contract_call(accounts, tester, console_mode): tx = tester.revertStrings(1, {"from": accounts[1]}) assert tx.status == 0 - tx._await_transaction() + tx._await_transaction(1, True) def test_await_conf_successful_contract_deploy(accounts, BrownieTester): tx = BrownieTester.deploy(True, {"from": accounts[0]}).tx assert tx.status == 1 - tx._await_transaction() + tx._await_transaction(1, True) def test_await_conf_failed_contract_deploy(accounts, BrownieTester, console_mode): tx = BrownieTester.deploy(False, {"from": accounts[0]}) assert tx.status == 0 - tx._await_transaction() + tx._await_transaction(1, True) def test_transaction_confirmations(accounts, chain):