Merge pull request #2043 from cygnusv/block

Improve block confirmation logic and tests
pull/2087/head
K Prasch 2020-06-09 13:53:36 -07:00 committed by GitHub
commit 60043adffb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 385 additions and 142 deletions

View File

@ -25,6 +25,7 @@ from constant_sorrow.constants import NOT_RUNNING, UNKNOWN_DEVELOPMENT_CHAIN_ID
from cytoolz.dicttoolz import dissoc
from eth_account import Account
from eth_account.messages import encode_defunct
from eth_typing import HexStr
from eth_typing.evm import BlockNumber, ChecksumAddress
from eth_utils import to_canonical_address, to_checksum_address
from geth import LoggingMixin
@ -41,7 +42,10 @@ from typing import Union
from web3 import Web3
from web3.contract import Contract
from web3.types import Wei, TxReceipt
from web3._utils.threads import Timeout
from web3.exceptions import TimeExhausted, TransactionNotFound
from nucypher.blockchain.eth.constants import AVERAGE_BLOCK_TIME_IN_SECONDS
from nucypher.config.constants import DEFAULT_CONFIG_ROOT, DEPLOY_DIR, USER_LOG_DIR
UNKNOWN_DEVELOPMENT_CHAIN_ID.bool_value(True)
@ -58,8 +62,10 @@ class Web3ClientConnectionFailed(Web3ClientError):
class Web3ClientUnexpectedVersionString(Web3ClientError):
pass
# TODO: Consider creating a ChainInventory class and/or moving this to a separate module
PUBLIC_CHAINS = {0: "Olympic",
1: "Mainnet",
2: "Morden",
@ -92,7 +98,6 @@ POA_CHAINS = { # TODO: This list is incomplete, but it suffices for the moment
class EthereumClient:
is_local = False
GETH = 'Geth'
@ -103,9 +108,13 @@ class EthereumClient:
ETHEREUM_TESTER = 'EthereumTester' # (PyEVM)
CLEF = 'Clef' # Signer-only
PEERING_TIMEOUT = 30 # seconds
PEERING_TIMEOUT = 30 # seconds
SYNC_TIMEOUT_DURATION = 60 # seconds to wait for various blockchain syncing endeavors
SYNC_SLEEP_DURATION = 5 # seconds
SYNC_SLEEP_DURATION = 5 # seconds
BLOCK_CONFIRMATIONS_POLLING_TIME = 3 # seconds
TRANSACTION_POLLING_TIME = 0.5 # seconds
COOLING_TIME = 5 # seconds
STALECHECK_ALLOWABLE_DELAY = 30 # seconds
class ConnectionNotEstablished(RuntimeError):
pass
@ -116,6 +125,27 @@ class EthereumClient:
class UnknownAccount(ValueError):
pass
class TransactionBroadcastError(RuntimeError):
pass
class NotEnoughConfirmations(TransactionBroadcastError):
pass
class TransactionTimeout(TransactionBroadcastError):
pass
class ChainReorganizationDetected(TransactionBroadcastError):
"""Raised when block confirmations logic detects that a TX was lost due to a chain reorganization"""
error_message = ("Chain re-organization detected: Transaction {transaction_hash} was reported to be in "
"block {block_hash}, but it's not there anymore")
def __init__(self, receipt):
self.receipt = receipt
self.message = self.error_message.format(transaction_hash=Web3.toHex(receipt['transactionHash']),
block_hash=Web3.toHex(receipt['blockHash']))
super().__init__(self.message)
def __init__(self,
w3,
node_technology: str,
@ -254,14 +284,84 @@ class EthereumClient:
def coinbase(self) -> ChecksumAddress:
return self.w3.eth.coinbase
def wait_for_receipt(self, transaction_hash: str, timeout: int) -> TxReceipt:
receipt = self.w3.eth.waitForTransactionReceipt(transaction_hash=transaction_hash, timeout=timeout)
def wait_for_receipt(self,
transaction_hash: str,
timeout: float,
confirmations: int = 0) -> TxReceipt:
receipt: TxReceipt = None
if confirmations:
# If we're waiting for confirmations, we may as well let pass some time initially to make everything easier
time.sleep(self.COOLING_TIME)
# We'll keep trying to get receipts until there are enough confirmations or the timeout happens
with Timeout(seconds=timeout, exception=self.TransactionTimeout) as timeout_context:
while not receipt:
try:
receipt = self.block_until_enough_confirmations(transaction_hash=transaction_hash,
timeout=timeout,
confirmations=confirmations)
except (self.ChainReorganizationDetected, self.NotEnoughConfirmations, TimeExhausted):
timeout_context.sleep(self.BLOCK_CONFIRMATIONS_POLLING_TIME)
continue
else:
# If not asking for confirmations, just use web3 and assume the returned receipt is final
try:
receipt = self.w3.eth.waitForTransactionReceipt(transaction_hash=transaction_hash,
timeout=timeout,
poll_latency=self.TRANSACTION_POLLING_TIME)
except TimeExhausted:
raise # TODO: #1504 - Handle transaction timeout
return receipt
def block_until_enough_confirmations(self, transaction_hash: str, timeout: float, confirmations: int) -> dict:
receipt: TxReceipt = self.w3.eth.waitForTransactionReceipt(transaction_hash=transaction_hash,
timeout=timeout,
poll_latency=self.TRANSACTION_POLLING_TIME)
preliminary_block_hash = Web3.toHex(receipt['blockHash'])
tx_block_number = Web3.toInt(receipt['blockNumber'])
self.log.info(f"Transaction {Web3.toHex(transaction_hash)} is preliminarily included in "
f"block {preliminary_block_hash}")
confirmations_timeout = self._calculate_confirmations_timeout(confirmations)
confirmations_so_far = 0
with Timeout(seconds=confirmations_timeout, exception=self.NotEnoughConfirmations) as timeout_context:
while confirmations_so_far < confirmations:
timeout_context.sleep(self.BLOCK_CONFIRMATIONS_POLLING_TIME)
self.check_transaction_is_on_chain(receipt=receipt)
confirmations_so_far = self.block_number - tx_block_number
self.log.info(f"We have {confirmations_so_far} confirmations. "
f"Waiting for {confirmations - confirmations_so_far} more.")
return receipt
@staticmethod
def _calculate_confirmations_timeout(confirmations):
confirmations_timeout = 3 * AVERAGE_BLOCK_TIME_IN_SECONDS * confirmations
return confirmations_timeout
def check_transaction_is_on_chain(self, receipt: TxReceipt) -> bool:
transaction_hash = Web3.toHex(receipt['transactionHash'])
try:
new_receipt = self.w3.eth.getTransactionReceipt(transaction_hash=transaction_hash)
except TransactionNotFound:
reorg_detected = True
else:
reorg_detected = receipt['blockHash'] != new_receipt['blockHash']
if reorg_detected:
exception = self.ChainReorganizationDetected(receipt=receipt)
self.log.info(exception.message)
raise exception
# TODO: Consider adding an optional param in this exception to include extra info (e.g. new block)
return True
def sign_transaction(self, transaction_dict: dict) -> bytes:
raise NotImplementedError
def get_transaction(self, transaction_hash) -> str:
def get_transaction(self, transaction_hash) -> dict:
return self.w3.eth.getTransaction(transaction_hash=transaction_hash)
def send_transaction(self, transaction_dict: dict) -> str:
@ -278,12 +378,15 @@ class EthereumClient:
"""
return self.w3.eth.sign(account, data=message)
def get_blocktime(self):
highest_block = self.w3.eth.getBlock('latest')
now = highest_block['timestamp']
return now
def _has_latest_block(self) -> bool:
# TODO: Investigate using `web3.middleware.make_stalecheck_middleware` #2060
# check that our local chain data is up to date
return (
time.time() -
self.w3.eth.getBlock(self.w3.eth.blockNumber)['timestamp']
) < 30
return (time.time() - self.get_blocktime()) < self.STALECHECK_ALLOWABLE_DELAY
def sync(self, timeout: int = 120, quiet: bool = False):
@ -312,7 +415,7 @@ class EthereumClient:
self.log.info(f"Waiting for {self.chain_name.capitalize()} chain synchronization to begin")
while not self.syncing:
time.sleep(0)
check_for_timeout(t=self.SYNC_TIMEOUT_DURATION*2)
check_for_timeout(t=self.SYNC_TIMEOUT_DURATION * 2)
while True:
syncdata = self.syncing
@ -344,7 +447,7 @@ class GethClient(EthereumClient):
return self.w3.geth.admin.peers()
def new_account(self, password: str) -> str:
new_account = self.w3.geth.personal.newAccount(password)
new_account = self.w3.geth.personal.new_account(password)
return to_checksum_address(new_account) # cast and validate
def unlock_account(self, account: str, password: str, duration: int = None):
@ -363,10 +466,10 @@ class GethClient(EthereumClient):
debug_message += " with no password."
self.log.debug(debug_message)
return self.w3.geth.personal.unlockAccount(account, password, duration)
return self.w3.geth.personal.unlock_account(account, password, duration)
def lock_account(self, account):
return self.w3.geth.personal.lockAccount(account)
return self.w3.geth.personal.lock_account(account)
def sign_transaction(self, transaction_dict: dict) -> bytes:
@ -383,7 +486,7 @@ class GethClient(EthereumClient):
@property
def wallets(self):
return self.w3.manager.request_blocking("personal_listWallets", [])
return self.w3.geth.personal.list_wallets()
class ParityClient(EthereumClient):
@ -396,18 +499,17 @@ class ParityClient(EthereumClient):
return self.w3.manager.request_blocking("parity_netPeers", [])
def new_account(self, password: str) -> str:
new_account = self.w3.parity.personal.newAccount(password)
new_account = self.w3.parity.personal.new_account(password)
return to_checksum_address(new_account) # cast and validate
def unlock_account(self, account, password, duration: int = None) -> bool:
return self.w3.parity.personal.unlockAccount(account, password, duration)
return self.w3.parity.personal.unlock_account(account, password, duration)
def lock_account(self, account):
return self.w3.parity.personal.lockAccount(account)
return self.w3.parity.personal.lock_account(account)
class GanacheClient(EthereumClient):
is_local = True
def unlock_account(self, *args, **kwargs) -> bool:
@ -418,8 +520,8 @@ class GanacheClient(EthereumClient):
class InfuraClient(EthereumClient):
is_local = False
TRANSACTION_POLLING_TIME = 2 # seconds
def unlock_account(self, *args, **kwargs) -> bool:
return True
@ -429,7 +531,6 @@ class InfuraClient(EthereumClient):
class EthereumTesterClient(EthereumClient):
is_local = True
def unlock_account(self, account, password, duration: int = None) -> bool:
@ -486,7 +587,6 @@ class EthereumTesterClient(EthereumClient):
class NuCypherGethProcess(LoggingMixin, BaseGethProcess):
IPC_PROTOCOL = 'http'
IPC_FILENAME = 'geth.ipc'
VERBOSITY = 5
@ -540,7 +640,6 @@ class NuCypherGethProcess(LoggingMixin, BaseGethProcess):
class NuCypherGethDevProcess(NuCypherGethProcess):
_CHAIN_NAME = 'poa-development'
def __init__(self, config_root: str = None, *args, **kwargs):
@ -567,7 +666,6 @@ class NuCypherGethDevProcess(NuCypherGethProcess):
class NuCypherGethDevnetProcess(NuCypherGethProcess):
IPC_PROTOCOL = 'file'
GENESIS_FILENAME = 'testnet_genesis.json'
GENESIS_SOURCE_FILEPATH = os.path.join(DEPLOY_DIR, GENESIS_FILENAME)
@ -646,7 +744,6 @@ class NuCypherGethDevnetProcess(NuCypherGethProcess):
class NuCypherGethGoerliProcess(NuCypherGethProcess):
IPC_PROTOCOL = 'file'
GENESIS_FILENAME = 'testnet_genesis.json'
GENESIS_SOURCE_FILEPATH = os.path.join(DEPLOY_DIR, GENESIS_FILENAME)

View File

@ -591,7 +591,7 @@ class StakingEscrowDeployer(BaseContractDeployer, UpgradeableContractMixin, Owna
target_contract=the_escrow_contract,
deployer_address=self.deployer_address)
dispatcher_receipts = dispatcher_deployer.deploy(gas_limit=gas_limit)
dispatcher_receipts = dispatcher_deployer.deploy(gas_limit=gas_limit, confirmations=confirmations)
dispatcher_deploy_receipt = dispatcher_receipts[dispatcher_deployer.deployment_steps[0]]
if progress:
progress.update(1)
@ -615,11 +615,14 @@ class StakingEscrowDeployer(BaseContractDeployer, UpgradeableContractMixin, Owna
# This is the end of deployment without activation: the contract is now idle, waiting for activation
return preparation_receipts
else: # deployment_mode is FULL
activation_receipts = self.activate(gas_limit=gas_limit, progress=progress, emitter=emitter)
activation_receipts = self.activate(gas_limit=gas_limit,
progress=progress,
confirmations=confirmations,
emitter=emitter)
self.deployment_receipts.update(activation_receipts)
return self.deployment_receipts
def activate(self, gas_limit: int = None, progress=None, emitter=None):
def activate(self, gas_limit: int = None, progress=None, emitter=None, confirmations: int = 0):
self._contract = self._get_deployed_contract()
if not self.ready_to_activate:
@ -638,6 +641,7 @@ class StakingEscrowDeployer(BaseContractDeployer, UpgradeableContractMixin, Owna
# TODO: Confirmations / Successful Transaction Indicator / Events ?? - #1193, #1194
approve_reward_receipt = self.blockchain.send_transaction(contract_function=approve_reward_function,
sender_address=self.deployer_address,
confirmations=confirmations,
payload=origin_args)
if progress:
progress.update(1)
@ -648,6 +652,7 @@ class StakingEscrowDeployer(BaseContractDeployer, UpgradeableContractMixin, Owna
init_function = self._contract.functions.initialize(self.economics.erc20_reward_supply)
init_receipt = self.blockchain.send_transaction(contract_function=init_function,
sender_address=self.deployer_address,
confirmations=confirmations,
payload=origin_args)
if progress:
progress.update(1)
@ -754,7 +759,7 @@ class PolicyManagerDeployer(BaseContractDeployer, UpgradeableContractMixin, Owna
target_contract=policy_manager_contract,
deployer_address=self.deployer_address)
proxy_deploy_receipt = proxy_deployer.deploy(gas_limit=gas_limit)
proxy_deploy_receipt = proxy_deployer.deploy(gas_limit=gas_limit, confirmations=confirmations)
proxy_deploy_receipt = proxy_deploy_receipt[proxy_deployer.deployment_steps[0]]
if progress:
progress.update(1)
@ -777,6 +782,7 @@ class PolicyManagerDeployer(BaseContractDeployer, UpgradeableContractMixin, Owna
set_policy_manager_function = self.staking_contract.functions.setPolicyManager(wrapped_contract.address)
set_policy_manager_receipt = self.blockchain.send_transaction(contract_function=set_policy_manager_function,
sender_address=self.deployer_address,
confirmations=confirmations,
payload=tx_args)
if progress:
progress.update(1)
@ -1060,7 +1066,7 @@ class AdjudicatorDeployer(BaseContractDeployer, UpgradeableContractMixin, Ownabl
]
return super().check_deployment_readiness(additional_rules=adjudicator_deployment_rules, *args, **kwargs)
def _deploy_essential(self, contract_version: str, gas_limit: int = None, **overrides):
def _deploy_essential(self, contract_version: str, gas_limit: int = None, confirmations: int = 0, **overrides):
args = self.economics.slashing_deployment_parameters
constructor_kwargs = {
"_hashAlgorithm": args[0],
@ -1077,6 +1083,7 @@ class AdjudicatorDeployer(BaseContractDeployer, UpgradeableContractMixin, Ownabl
self.registry,
self.contract_name,
gas_limit=gas_limit,
confirmations=confirmations,
contract_version=contract_version,
**constructor_kwargs)
return adjudicator_contract, deploy_receipt
@ -1088,6 +1095,7 @@ class AdjudicatorDeployer(BaseContractDeployer, UpgradeableContractMixin, Ownabl
contract_version: str = "latest",
ignore_deployed: bool = False,
emitter=None,
confirmations: int = 0,
**overrides) -> Dict[str, str]:
if deployment_mode not in (BARE, IDLE, FULL):
@ -1100,6 +1108,7 @@ class AdjudicatorDeployer(BaseContractDeployer, UpgradeableContractMixin, Ownabl
emitter.message(f"\nNext Transaction: {self.contract_name} Contract Creation", color='blue', bold=True)
adjudicator_contract, deploy_receipt = self._deploy_essential(contract_version=contract_version,
gas_limit=gas_limit,
confirmations=confirmations,
**overrides)
# This is the end of bare deployment.
@ -1118,7 +1127,7 @@ class AdjudicatorDeployer(BaseContractDeployer, UpgradeableContractMixin, Ownabl
target_contract=adjudicator_contract,
deployer_address=self.deployer_address)
proxy_deploy_receipts = proxy_deployer.deploy(gas_limit=gas_limit)
proxy_deploy_receipts = proxy_deployer.deploy(gas_limit=gas_limit, confirmations=confirmations)
proxy_deploy_receipt = proxy_deploy_receipts[proxy_deployer.deployment_steps[0]]
if progress:
progress.update(1)
@ -1139,6 +1148,7 @@ class AdjudicatorDeployer(BaseContractDeployer, UpgradeableContractMixin, Ownabl
set_adjudicator_function = self.staking_contract.functions.setAdjudicator(adjudicator_contract.address)
set_adjudicator_receipt = self.blockchain.send_transaction(contract_function=set_adjudicator_function,
sender_address=self.deployer_address,
confirmations=confirmations,
transaction_gas_limit=gas_limit)
if progress:
progress.update(1)

View File

@ -19,13 +19,18 @@ along with nucypher. If not, see <https://www.gnu.org/licenses/>.
import collections
import click
import maya
import os
import pprint
import requests
import time
from constant_sorrow.constants import (INSUFFICIENT_ETH, NO_BLOCKCHAIN_CONNECTION, NO_COMPILATION_PERFORMED,
NO_PROVIDER_PROCESS, READ_ONLY_INTERFACE, UNKNOWN_TX_STATUS)
from constant_sorrow.constants import (
INSUFFICIENT_ETH,
NO_BLOCKCHAIN_CONNECTION,
NO_COMPILATION_PERFORMED,
NO_PROVIDER_PROCESS,
READ_ONLY_INTERFACE,
UNKNOWN_TX_STATUS
)
from eth_tester import EthereumTester
from eth_tester.exceptions import TransactionFailed as TestTransactionFailed
from eth_utils import to_checksum_address
@ -40,9 +45,16 @@ from web3.middleware import geth_poa_middleware
from nucypher.blockchain.eth.clients import EthereumClient, POA_CHAINS
from nucypher.blockchain.eth.decorators import validate_checksum_address
from nucypher.blockchain.eth.providers import (_get_HTTP_provider, _get_IPC_provider, _get_auto_provider,
_get_infura_provider, _get_mock_test_provider, _get_pyevm_test_provider,
_get_test_geth_parity_provider, _get_websocket_provider)
from nucypher.blockchain.eth.providers import (
_get_auto_provider,
_get_HTTP_provider,
_get_infura_provider,
_get_IPC_provider,
_get_mock_test_provider,
_get_pyevm_test_provider,
_get_test_geth_parity_provider,
_get_websocket_provider
)
from nucypher.blockchain.eth.registry import BaseContractRegistry
from nucypher.blockchain.eth.sol.compile import SolidityCompiler
from nucypher.blockchain.eth.utils import get_transaction_name, prettify_eth_amount
@ -62,7 +74,7 @@ class BlockchainInterface:
ethereum contracts with the given web3 provider backend.
"""
TIMEOUT = 600 # seconds
TIMEOUT = 600 # seconds # TODO: Correlate with the gas strategy - #2070
DEFAULT_GAS_STRATEGY = 'medium'
GAS_STRATEGIES = {'glacial': time_based.glacial_gas_price_strategy, # 24h
@ -91,9 +103,6 @@ class BlockchainInterface:
class UnknownContract(InterfaceError):
pass
class NotEnoughConfirmations(InterfaceError):
pass
REASONS = {
INSUFFICIENT_ETH: 'insufficient funds for gas * price + value',
}
@ -143,7 +152,7 @@ class BlockchainInterface:
gas_strategy: Union[str, Callable] = DEFAULT_GAS_STRATEGY):
"""
A blockchain "network interface"; The circumflex wraps entirely around the bounds of
A blockchain "network interface"; the circumflex wraps entirely around the bounds of
contract operations including compilation, deployment, and execution.
TODO: #1502 - Move to API docs.
@ -537,8 +546,8 @@ class BlockchainInterface:
# Receipt
#
try:
receipt = self.client.wait_for_receipt(txhash, timeout=self.TIMEOUT)
try: # TODO: Handle block confirmation exceptions
receipt = self.client.wait_for_receipt(txhash, timeout=self.TIMEOUT, confirmations=confirmations)
except TimeExhausted:
# TODO: #1504 - Handle transaction timeout
raise
@ -550,13 +559,13 @@ class BlockchainInterface:
#
# Primary check
deployment_status = receipt.get('status', UNKNOWN_TX_STATUS)
if deployment_status == 0:
transaction_status = receipt.get('status', UNKNOWN_TX_STATUS)
if transaction_status == 0:
failure = f"Transaction transmitted, but receipt returned status code 0. " \
f"Full receipt: \n {pprint.pformat(receipt, indent=2)}"
raise self.InterfaceError(failure)
if deployment_status is UNKNOWN_TX_STATUS:
if transaction_status is UNKNOWN_TX_STATUS:
self.log.info(f"Unknown transaction status for {txhash} (receipt did not contain a status field)")
# Secondary check
@ -565,33 +574,10 @@ class BlockchainInterface:
raise self.InterfaceError(f"Transaction consumed 100% of transaction gas."
f"Full receipt: \n {pprint.pformat(receipt, indent=2)}")
# Block confirmations
if confirmations:
start = maya.now()
confirmations_so_far = self.get_confirmations(receipt)
while confirmations_so_far < confirmations:
self.log.info(f"So far, we've received {confirmations_so_far} confirmations. "
f"Waiting for {confirmations - confirmations_so_far} more.")
time.sleep(3)
confirmations_so_far = self.get_confirmations(receipt)
if (maya.now() - start).seconds > self.TIMEOUT:
raise self.NotEnoughConfirmations
return receipt
def get_confirmations(self, receipt: dict) -> int:
tx_block_number = receipt.get('blockNumber')
latest_block_number = self.w3.eth.blockNumber
confirmations = latest_block_number - tx_block_number
if confirmations < 0:
raise ValueError(f"Can't get number of confirmations for transaction {receipt['transactionHash'].hex()}, "
f"as it seems to come from {-confirmations} blocks in the future...")
return confirmations
def get_blocktime(self):
highest_block = self.w3.eth.getBlock('latest')
now = highest_block['timestamp']
return now
return self.client.get_blocktime()
@validate_checksum_address
def send_transaction(self,

View File

@ -312,7 +312,7 @@ class Stake:
result = self.unlock_datetime.slang_date()
else:
# TODO - #1509 EthAgent?
blocktime_epoch = self.staking_agent.blockchain.client.w3.eth.getBlock('latest').timestamp
blocktime_epoch = self.staking_agent.blockchain.client.get_blocktime()
delta = self.unlock_datetime.epoch - blocktime_epoch
result = delta
return result

View File

@ -439,7 +439,7 @@ def contracts(general_config, actor_options, mode, activate, gas, ignore_deploye
staking_escrow_address=escrow_address)
click.confirm(prompt, abort=True)
receipts = staking_escrow_deployer.activate()
receipts = staking_escrow_deployer.activate(gas_limit=gas, confirmations=confirmations)
for tx_name, receipt in receipts.items():
paint_receipt_summary(emitter=emitter,
receipt=receipt,

View File

@ -136,7 +136,7 @@ def events(general_config, registry_options, contract_name, from_block, to_block
if from_block is None:
# Sketch of logic for getting the approximate block height of current period start,
# so by default, this command only shows events of the current period
last_block = blockchain.client.w3.eth.blockNumber
last_block = blockchain.client.block_number
staking_agent = ContractAgency.get_agent(StakingEscrowAgent, registry=registry)
current_period = staking_agent.get_current_period()
current_period_start = datetime_at_period(period=current_period,

View File

@ -14,16 +14,24 @@ GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with nucypher. If not, see <https://www.gnu.org/licenses/>.
"""
import types
from os.path import abspath, dirname
import os
import pytest
from unittest.mock import PropertyMock
from nucypher.blockchain.eth.interfaces import BlockchainDeployerInterface
import maya
import pytest
from hexbytes import HexBytes
from nucypher.blockchain.eth.clients import EthereumClient
from nucypher.blockchain.eth.interfaces import BlockchainDeployerInterface, BlockchainInterfaceFactory
from nucypher.blockchain.eth.registry import InMemoryContractRegistry
from nucypher.blockchain.eth.sol.compile import SolidityCompiler, SourceDirs
from nucypher.crypto.powers import TransactingPower
# Prevents TesterBlockchain to be picked up by py.test as a test class
from tests.fixtures import _make_testerchain
from tests.mock.interfaces import MockBlockchain
from tests.utils.blockchain import TesterBlockchain as _TesterBlockchain
from tests.constants import (DEVELOPMENT_ETH_AIRDROP_AMOUNT, INSECURE_DEVELOPMENT_PASSWORD,
NUMBER_OF_ETH_TEST_ACCOUNTS, NUMBER_OF_STAKERS_IN_BLOCKCHAIN_TESTS,
@ -155,72 +163,28 @@ def test_multiversion_contract():
assert contract.functions.VERSION().call() == 2
def test_block_confirmations(testerchain, test_registry):
testerchain.TIMEOUT = 5 # Reduce timeout for tests, for the moment
def test_block_confirmations(testerchain, test_registry, mocker):
origin = testerchain.etherbase_account
# Mocks and test adjustments
testerchain.TIMEOUT = 5 # Reduce timeout for tests, for the moment
mocker.patch.object(testerchain.client, '_calculate_confirmations_timeout', return_value=1)
EthereumClient.BLOCK_CONFIRMATIONS_POLLING_TIME = 0.1
EthereumClient.COOLING_TIME = 0
# Let's try to deploy a simple contract (ReceiveApprovalMethodMock) with 1 confirmation.
# Since the testerchain doesn't automine, this fails.
with pytest.raises(testerchain.NotEnoughConfirmations):
_ = testerchain.deploy_contract(origin,
test_registry,
'ReceiveApprovalMethodMock',
confirmations=10)
# Since the testerchain doesn't mine new blocks automatically, this fails.
with pytest.raises(EthereumClient.TransactionTimeout):
_ = testerchain.deploy_contract(origin, test_registry, 'ReceiveApprovalMethodMock', confirmations=1)
# Trying again with no confirmation succeeds.
contract, _ = testerchain.deploy_contract(origin,
test_registry,
'ReceiveApprovalMethodMock')
contract, _ = testerchain.deploy_contract(origin, test_registry, 'ReceiveApprovalMethodMock')
# Trying a simple function of the contract with 1 confirmations fails too, for the same reason
tx_function = contract.functions.receiveApproval(origin, 0, origin, b'')
with pytest.raises(testerchain.NotEnoughConfirmations):
_ = testerchain.send_transaction(contract_function=tx_function,
sender_address=origin,
confirmations=1)
with pytest.raises(EthereumClient.TransactionTimeout):
_ = testerchain.send_transaction(contract_function=tx_function, sender_address=origin, confirmations=1)
# Trying again with no confirmation succeeds.
tx_receipt = testerchain.send_transaction(contract_function=tx_function,
sender_address=origin,
confirmations=0)
assert testerchain.get_confirmations(tx_receipt) == 0
testerchain.w3.eth.web3.testing.mine(1)
assert testerchain.get_confirmations(tx_receipt) == 1
# TODO: Find a way to test block confirmations. The following approach fails sometimes. Perhaps using a background threat that mines blocks?
# # Ok, I admit that the tests so far weren't very exciting, since we cannot directly test confirmations
# # as new blocks are not mined continuously in our test framework.
# # Let's do something hacky and monkey-patch the method that checks the number of confirmations to
# # mine a new block, say, each 5 seconds.
#
# get_confirmations = testerchain.get_confirmations
#
# def patched_get_confirmations(self, receipt):
# now = maya.now().second
# elapsed = now - patched_get_confirmations.timestamp
# blocks = elapsed // 5
# if blocks > 0:
# testerchain.w3.eth.web3.testing.mine(blocks)
# patched_get_confirmations.timestamp = now
# return get_confirmations(receipt)
#
# patched_get_confirmations.timestamp = maya.now().second
# testerchain.get_confirmations = types.MethodType(patched_get_confirmations, testerchain)
#
# # With a timeout of 30, now we can ask for 1 or 2 confirmations...
# testerchain.TIMEOUT = 30
# _ = testerchain.send_transaction(contract_function=tx_function,
# sender_address=origin,
# confirmations=1)
#
# _ = testerchain.send_transaction(contract_function=tx_function,
# sender_address=origin,
# confirmations=2)
#
# # ... but not 10, that's too much.
# with pytest.raises(testerchain.NotEnoughConfirmations):
# _ = testerchain.send_transaction(contract_function=tx_function,
# sender_address=origin,
# confirmations=10)
receipt = testerchain.send_transaction(contract_function=tx_function, sender_address=origin, confirmations=0)
assert receipt['status'] == 1

View File

@ -441,8 +441,8 @@ def _make_testerchain(mock_backend: bool = False) -> TesterBlockchain:
if mock_backend:
testerchain = MockBlockchain()
else:
testerchain = TesterBlockchain(eth_airdrop=not mock_backend,
free_transactions=True)
testerchain = TesterBlockchain(eth_airdrop=True, free_transactions=True)
return testerchain
@ -475,7 +475,6 @@ def testerchain(_testerchain) -> TesterBlockchain:
eth_amount = Web3().fromWei(spent, 'ether')
testerchain.log.info("Airdropped {} ETH {} -> {}".format(eth_amount, tx['from'], tx['to']))
# if not BlockchainInterfaceFactory.is_interface_initialized(provider_uri=TEST_PROVIDER_URI):
BlockchainInterfaceFactory.register_interface(interface=testerchain, force=True)
# Mock TransactingPower Consumption (Deployer)
testerchain.transacting_power = TransactingPower(password=INSECURE_DEVELOPMENT_PASSWORD,

View File

@ -20,6 +20,7 @@ from contextlib import contextmanager
from typing import Union
from nucypher.blockchain.eth.clients import EthereumClient
from nucypher.blockchain.eth.constants import PREALLOCATION_ESCROW_CONTRACT_NAME
from nucypher.blockchain.eth.networks import NetworksInventory
from nucypher.blockchain.eth.registry import (BaseContractRegistry, CanonicalRegistrySource,
@ -77,3 +78,9 @@ class MockBlockchain(TesterBlockchain):
def __init__(self):
super().__init__(mock_backend=True)
class MockEthereumClient(EthereumClient):
def __init__(self, w3):
super().__init__(w3, None, None, None, None)

View File

@ -0,0 +1,179 @@
"""
This file is part of nucypher.
nucypher is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
nucypher is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with nucypher. If not, see <https://www.gnu.org/licenses/>.
"""
import time
from unittest.mock import PropertyMock
import pytest
from hexbytes import HexBytes
from web3.exceptions import TransactionNotFound, TimeExhausted
from tests.mock.interfaces import MockEthereumClient
@pytest.fixture(scope='function')
def mock_ethereum_client(mocker):
web3_mock = mocker.Mock()
mock_client = MockEthereumClient(w3=web3_mock)
return mock_client
@pytest.fixture()
def receipt():
block_number_of_my_tx = 42
my_tx_hash = HexBytes('0xFabadaAcabada')
receipt = {
'transactionHash': my_tx_hash,
'blockNumber': block_number_of_my_tx,
'blockHash': HexBytes('0xBebeCafe')
}
return receipt
def test_check_transaction_is_on_chain(mocker, mock_ethereum_client, receipt):
# Mocking Web3 and EthereumClient
web3_mock = mock_ethereum_client.w3
web3_mock.eth.getTransactionReceipt = mocker.Mock(return_value=receipt)
# Test with no chain reorganizations:
# While web3 keeps returning the same receipt that we initially had, all good
assert mock_ethereum_client.check_transaction_is_on_chain(receipt=receipt)
# Test with chain re-organizations:
# Let's assume that our TX ends up mined in a different block, and we receive a new receipt
new_receipt = dict(receipt)
new_receipt.update({'blockHash': HexBytes('0xBebeCebada')})
web3_mock.eth.getTransactionReceipt = mocker.Mock(return_value=new_receipt)
exception = mock_ethereum_client.ChainReorganizationDetected
message = exception(receipt=receipt).message
with pytest.raises(exception, match=message):
_ = mock_ethereum_client.check_transaction_is_on_chain(receipt=receipt)
# Another example: there has been a chain reorganization and our beloved TX is gone for good:
web3_mock.eth.getTransactionReceipt = mocker.Mock(side_effect=TransactionNotFound)
with pytest.raises(exception, match=message):
_ = mock_ethereum_client.check_transaction_is_on_chain(receipt=receipt)
def test_block_until_enough_confirmations(mocker, mock_ethereum_client, receipt):
my_tx_hash = receipt['transactionHash']
block_number_of_my_tx = receipt['blockNumber']
# Test that web3's TimeExhausted is propagated:
web3_mock = mock_ethereum_client.w3
web3_mock.eth.waitForTransactionReceipt = mocker.Mock(side_effect=TimeExhausted)
with pytest.raises(TimeExhausted):
mock_ethereum_client.block_until_enough_confirmations(transaction_hash=my_tx_hash,
timeout=1,
confirmations=1)
# Test that NotEnoughConfirmations is raised when there are not enough confirmations.
# In this case, we're going to mock eth.blockNumber to be stuck
web3_mock.eth.waitForTransactionReceipt = mocker.Mock(return_value=receipt)
web3_mock.eth.getTransactionReceipt = mocker.Mock(return_value=receipt)
type(web3_mock.eth).blockNumber = PropertyMock(return_value=block_number_of_my_tx) # See docs of PropertyMock
# Additional adjustments to make the test faster
mocker.patch.object(mock_ethereum_client, '_calculate_confirmations_timeout', return_value=0.1)
mock_ethereum_client.BLOCK_CONFIRMATIONS_POLLING_TIME = 0
with pytest.raises(mock_ethereum_client.NotEnoughConfirmations):
mock_ethereum_client.block_until_enough_confirmations(transaction_hash=my_tx_hash,
timeout=1,
confirmations=1)
# Test that block_until_enough_confirmations keeps iterating until the required confirmations are obtained
required_confirmations = 3
new_blocks_sequence = range(block_number_of_my_tx, block_number_of_my_tx + required_confirmations + 1)
type(web3_mock.eth).blockNumber = PropertyMock(side_effect=new_blocks_sequence) # See docs of PropertyMock
spy_check_transaction = mocker.spy(mock_ethereum_client, 'check_transaction_is_on_chain')
returned_receipt = mock_ethereum_client.block_until_enough_confirmations(transaction_hash=my_tx_hash,
timeout=1,
confirmations=required_confirmations)
assert receipt == returned_receipt
assert required_confirmations + 1 == spy_check_transaction.call_count
def test_wait_for_receipt_no_confirmations(mocker, mock_ethereum_client, receipt):
my_tx_hash = receipt['transactionHash']
# Test that web3's TimeExhausted is propagated:
web3_mock = mock_ethereum_client.w3
web3_mock.eth.waitForTransactionReceipt = mocker.Mock(side_effect=TimeExhausted)
with pytest.raises(TimeExhausted):
_ = mock_ethereum_client.wait_for_receipt(transaction_hash=my_tx_hash, timeout=1, confirmations=0)
web3_mock.eth.waitForTransactionReceipt.assert_called_once_with(transaction_hash=my_tx_hash,
timeout=1,
poll_latency=MockEthereumClient.TRANSACTION_POLLING_TIME)
# Test that when web3's layer returns the receipt, we get that receipt
web3_mock.eth.waitForTransactionReceipt = mocker.Mock(return_value=receipt)
returned_receipt = mock_ethereum_client.wait_for_receipt(transaction_hash=my_tx_hash, timeout=1, confirmations=0)
assert receipt == returned_receipt
web3_mock.eth.waitForTransactionReceipt.assert_called_once_with(transaction_hash=my_tx_hash,
timeout=1,
poll_latency=MockEthereumClient.TRANSACTION_POLLING_TIME)
def test_wait_for_receipt_with_confirmations(mocker, mock_ethereum_client, receipt):
my_tx_hash = receipt['transactionHash']
mock_ethereum_client.COOLING_TIME = 0 # Don't make test unnecessarily slow
time_spy = mocker.spy(time, 'sleep')
# timeout_spy = mocker.spy(Timeout, 'check') # FIXME
# First, let's make a simple, successful call to check that:
# - The same receipt goes through
# - The cooling time is respected
mock_ethereum_client.block_until_enough_confirmations = mocker.Mock(return_value=receipt)
returned_receipt = mock_ethereum_client.wait_for_receipt(transaction_hash=my_tx_hash, timeout=1, confirmations=1)
assert receipt == returned_receipt
time_spy.assert_called_once_with(mock_ethereum_client.COOLING_TIME)
# timeout_spy.assert_not_called() # FIXME
# Test that wait_for_receipt finishes when a receipt is returned by block_until_enough_confirmations
sequence_of_events = (
TimeExhausted,
mock_ethereum_client.ChainReorganizationDetected(receipt),
mock_ethereum_client.NotEnoughConfirmations,
receipt
)
timeout = None
mock_ethereum_client.BLOCK_CONFIRMATIONS_POLLING_TIME = 0
mock_ethereum_client.block_until_enough_confirmations = mocker.Mock(side_effect=sequence_of_events)
returned_receipt = mock_ethereum_client.wait_for_receipt(transaction_hash=my_tx_hash, timeout=timeout, confirmations=1)
assert receipt == returned_receipt
# assert timeout_spy.call_count == 3 # FIXME
# Test that a TransactionTimeout is thrown when no receipt is found during the given time
timeout = 0.1
sequence_of_events = [TimeExhausted] * 10
mock_ethereum_client.BLOCK_CONFIRMATIONS_POLLING_TIME = 0.015
mock_ethereum_client.block_until_enough_confirmations = mocker.Mock(side_effect=sequence_of_events)
with pytest.raises(mock_ethereum_client.TransactionTimeout):
_ = mock_ethereum_client.wait_for_receipt(transaction_hash=my_tx_hash,
timeout=timeout,
confirmations=1)

View File

@ -20,8 +20,9 @@ import maya
import os
from eth_tester.exceptions import TransactionFailed
from eth_utils import to_canonical_address
from hexbytes import HexBytes
from twisted.logger import Logger
from typing import List, Tuple
from typing import List, Tuple, Union
from web3 import Web3
from nucypher.blockchain.economics import BaseEconomics, StandardTokenEconomics
@ -266,16 +267,16 @@ class TesterBlockchain(BlockchainDeployerInterface):
accounts = set(self.client.accounts)
return list(accounts.difference(assigned_accounts))
def wait_for_receipt(self, txhash: bytes, timeout: int = None) -> dict:
def wait_for_receipt(self, txhash: Union[bytes, str, HexBytes], timeout: int = None) -> dict:
"""Wait for a transaction receipt and return it"""
timeout = timeout or self.TIMEOUT
result = self.w3.eth.waitForTransactionReceipt(txhash, timeout=timeout)
result = self.client.wait_for_receipt(transaction_hash=txhash, timeout=timeout)
if result.status == 0:
raise TransactionFailed()
return result
def get_block_number(self) -> int:
return self.client.w3.eth.blockNumber
return self.client.block_number
def read_storage_slot(self, address, slot):
# https://github.com/ethereum/web3.py/issues/1490