diff --git a/eth_tester_rpc/cli.py b/eth_tester_rpc/cli.py new file mode 100644 index 0000000..0ace09f --- /dev/null +++ b/eth_tester_rpc/cli.py @@ -0,0 +1,52 @@ +import random + +import click + + +from .server import get_application +from .utils.compat_threading import ( + make_server, + spawn, + sleep, +) + + +@click.command() +@click.option( + '--host', + '-h', + default='localhost', +) +@click.option( + '--port', + '-p', + default=8545, + type=int, +) +def runserver(host, port): + application = get_application() + + print(application.rpc_methods.web3_clientVersion(None)) + + print("\nListening on %s:%s" % (host, port)) + + server = make_server( + host, + port, + application, + ) + + spawn(server.serve_forever) + + try: + while True: + sleep(random.random()) + except KeyboardInterrupt: + try: + server.stop() + except AttributeError: + server.shutdown() + + +if __name__ == "__main__": + runserver() diff --git a/eth_tester_rpc/rpc.py b/eth_tester_rpc/rpc.py new file mode 100644 index 0000000..154ea88 --- /dev/null +++ b/eth_tester_rpc/rpc.py @@ -0,0 +1,373 @@ +import operator +import random +import sys +from functools import ( + partial, +) + +from eth_tester import ( + EthereumTester, +) +from eth_tester.exceptions import ( + BlockNotFound, + FilterNotFound, + TransactionNotFound, + ValidationError, +) +from eth_utils import ( + decode_hex, + encode_hex, + is_null, + keccak, +) + +from .utils.formatters import ( + apply_formatter_if, +) +from .utils.toolz import ( + compose, + curry, + excepts, +) + + +def not_implemented(*args, **kwargs): + raise NotImplementedError("RPC method not implemented") + + +@curry +def call_eth_tester(fn_name, eth_tester, *fn_args, **fn_kwargs): + if fn_kwargs is None: + fn_kwargs = {} + return getattr(eth_tester, fn_name)(*fn_args, **fn_kwargs) + + +def without_eth_tester(fn): + # workaround for: https://github.com/pytoolz/cytoolz/issues/103 + # @functools.wraps(fn) + def inner(eth_tester, params): + return fn(params) + return inner + + +def without_params(fn): + # workaround for: https://github.com/pytoolz/cytoolz/issues/103 + # @functools.wraps(fn) + def inner(eth_tester, params): + return fn(eth_tester) + return inner + + +@curry +def preprocess_params(eth_tester, params, preprocessor_fn): + return eth_tester, preprocessor_fn(params) + + +def static_return(value): + def inner(*args, **kwargs): + return value + return inner + + +def client_version(eth_tester, params): + # TODO: account for the backend that is in use. + from eth_tester import __version__ + return "EthereumTester/{version}/{platform}/python{v.major}.{v.minor}.{v.micro}".format( + version=__version__, + v=sys.version_info, + platform=sys.platform, + ) + + +@curry +def null_if_excepts(exc_type, fn): + return excepts( + exc_type, + fn, + static_return(None), + ) + + +null_if_block_not_found = null_if_excepts(BlockNotFound) +null_if_transaction_not_found = null_if_excepts(TransactionNotFound) +null_if_filter_not_found = null_if_excepts(FilterNotFound) +null_if_indexerror = null_if_excepts(IndexError) + + +@null_if_indexerror +@null_if_block_not_found +def get_transaction_by_block_hash_and_index(eth_tester, params): + block_hash, transaction_index = params + block = eth_tester.get_block_by_hash(block_hash, full_transactions=True) + transaction = block['transactions'][transaction_index] + return transaction + + +@null_if_indexerror +@null_if_block_not_found +def get_transaction_by_block_number_and_index(eth_tester, params): + block_number, transaction_index = params + block = eth_tester.get_block_by_number(block_number, full_transactions=True) + transaction = block['transactions'][transaction_index] + return transaction + + +def create_log_filter(eth_tester, params): + filter_params = params[0] + filter_id = eth_tester.create_log_filter(**filter_params) + return filter_id + + +def get_logs(eth_tester, params): + filter_params = params[0] + logs = eth_tester.get_logs(**filter_params) + return logs + + +def _generate_random_private_key(): + """ + WARNING: This is not a secure way to generate private keys and should only + be used for testing purposes. + """ + return encode_hex(bytes(bytearray(( + random.randint(0, 255) + for _ in range(32) + )))) + + +@without_params +def create_new_account(eth_tester): + return eth_tester.add_account(_generate_random_private_key()) + + +def personal_send_transaction(eth_tester, params): + transaction, password = params + + try: + eth_tester.unlock_account(transaction['from'], password) + transaction_hash = eth_tester.send_transaction(transaction) + finally: + eth_tester.lock_account(transaction['from']) + + return transaction_hash + + +API_ENDPOINTS = { + 'web3': { + 'clientVersion': client_version, + 'sha3': compose( + encode_hex, + keccak, + decode_hex, + without_eth_tester(operator.itemgetter(0)), + ), + }, + 'net': { + 'version': not_implemented, + 'peerCount': not_implemented, + 'listening': not_implemented, + }, + 'eth': { + 'protocolVersion': not_implemented, + 'syncing': not_implemented, + 'coinbase': compose( + operator.itemgetter(0), + call_eth_tester('get_accounts'), + ), + 'mining': not_implemented, + 'hashrate': not_implemented, + 'gasPrice': not_implemented, + 'accounts': call_eth_tester('get_accounts'), + 'blockNumber': compose( + operator.itemgetter('number'), + call_eth_tester('get_block_by_number', fn_kwargs={'block_number': 'latest'}), + ), + 'getBalance': call_eth_tester('get_balance'), + 'getStorageAt': not_implemented, + 'getTransactionCount': call_eth_tester('get_nonce'), + 'getBlockTransactionCountByHash': null_if_block_not_found(compose( + len, + operator.itemgetter('transactions'), + call_eth_tester('get_block_by_hash'), + )), + 'getBlockTransactionCountByNumber': null_if_block_not_found(compose( + len, + operator.itemgetter('transactions'), + call_eth_tester('get_block_by_number'), + )), + 'getUncleCountByBlockHash': null_if_block_not_found(compose( + len, + operator.itemgetter('uncles'), + call_eth_tester('get_block_by_hash'), + )), + 'getUncleCountByBlockNumber': null_if_block_not_found(compose( + len, + operator.itemgetter('uncles'), + call_eth_tester('get_block_by_number'), + )), + 'getCode': call_eth_tester('get_code'), + 'sign': not_implemented, + 'sendTransaction': call_eth_tester('send_transaction'), + 'sendRawTransaction': call_eth_tester('send_raw_transaction'), + 'call': call_eth_tester('call'), # TODO: untested + 'estimateGas': call_eth_tester('estimate_gas'), # TODO: untested + 'getBlockByHash': null_if_block_not_found(call_eth_tester('get_block_by_hash')), + 'getBlockByNumber': null_if_block_not_found(call_eth_tester('get_block_by_number')), + 'getTransactionByHash': null_if_transaction_not_found( + call_eth_tester('get_transaction_by_hash') + ), + 'getTransactionByBlockHashAndIndex': get_transaction_by_block_hash_and_index, + 'getTransactionByBlockNumberAndIndex': get_transaction_by_block_number_and_index, + 'getTransactionReceipt': null_if_transaction_not_found(compose( + apply_formatter_if( + compose(is_null, operator.itemgetter('block_number')), + static_return(None), + ), + call_eth_tester('get_transaction_receipt'), + )), + 'getUncleByBlockHashAndIndex': not_implemented, + 'getUncleByBlockNumberAndIndex': not_implemented, + 'getCompilers': not_implemented, + 'compileLLL': not_implemented, + 'compileSolidity': not_implemented, + 'compileSerpent': not_implemented, + 'newFilter': create_log_filter, + 'newBlockFilter': call_eth_tester('create_block_filter'), + 'newPendingTransactionFilter': call_eth_tester('create_pending_transaction_filter'), + 'uninstallFilter': excepts( + FilterNotFound, + compose( + is_null, + call_eth_tester('delete_filter'), + ), + static_return(False), + ), + 'getFilterChanges': null_if_filter_not_found(call_eth_tester('get_only_filter_changes')), + 'getFilterLogs': null_if_filter_not_found(call_eth_tester('get_all_filter_logs')), + 'getLogs': get_logs, + 'getWork': not_implemented, + 'submitWork': not_implemented, + 'submitHashrate': not_implemented, + }, + 'db': { + 'putString': not_implemented, + 'getString': not_implemented, + 'putHex': not_implemented, + 'getHex': not_implemented, + }, + 'shh': { + 'post': not_implemented, + 'version': not_implemented, + 'newIdentity': not_implemented, + 'hasIdentity': not_implemented, + 'newGroup': not_implemented, + 'addToGroup': not_implemented, + 'newFilter': not_implemented, + 'uninstallFilter': not_implemented, + 'getFilterChanges': not_implemented, + 'getMessages': not_implemented, + }, + 'admin': { + 'addPeer': not_implemented, + 'datadir': not_implemented, + 'nodeInfo': not_implemented, + 'peers': not_implemented, + 'setSolc': not_implemented, + 'startRPC': not_implemented, + 'startWS': not_implemented, + 'stopRPC': not_implemented, + 'stopWS': not_implemented, + }, + 'debug': { + 'backtraceAt': not_implemented, + 'blockProfile': not_implemented, + 'cpuProfile': not_implemented, + 'dumpBlock': not_implemented, + 'gtStats': not_implemented, + 'getBlockRLP': not_implemented, + 'goTrace': not_implemented, + 'memStats': not_implemented, + 'seedHashSign': not_implemented, + 'setBlockProfileRate': not_implemented, + 'setHead': not_implemented, + 'stacks': not_implemented, + 'startCPUProfile': not_implemented, + 'startGoTrace': not_implemented, + 'stopCPUProfile': not_implemented, + 'stopGoTrace': not_implemented, + 'traceBlock': not_implemented, + 'traceBlockByNumber': not_implemented, + 'traceBlockByHash': not_implemented, + 'traceBlockFromFile': not_implemented, + 'traceTransaction': not_implemented, + 'verbosity': not_implemented, + 'vmodule': not_implemented, + 'writeBlockProfile': not_implemented, + 'writeMemProfile': not_implemented, + }, + 'miner': { + 'makeDAG': not_implemented, + 'setExtra': not_implemented, + 'setGasPrice': not_implemented, + 'start': not_implemented, + 'startAutoDAG': not_implemented, + 'stop': not_implemented, + 'stopAutoDAG': not_implemented, + }, + 'personal': { + 'ecRecover': not_implemented, + 'importRawKey': call_eth_tester('add_account'), + 'listAccounts': call_eth_tester('get_accounts'), + 'lockAccount': excepts( + ValidationError, + compose(static_return(True), call_eth_tester('lock_account')), + static_return(False), + ), + 'newAccount': create_new_account, + 'unlockAccount': excepts( + ValidationError, + compose(static_return(True), call_eth_tester('unlock_account')), + static_return(False), + ), + 'sendTransaction': personal_send_transaction, + 'sign': not_implemented, + }, + 'testing': { + 'timeTravel': call_eth_tester('time_travel'), + }, + 'txpool': { + 'content': not_implemented, + 'inspect': not_implemented, + 'status': not_implemented, + }, + 'evm': { + 'mine': call_eth_tester('mine_blocks'), + 'revert': call_eth_tester('revert_to_snapshot'), + 'snapshot': call_eth_tester('take_snapshot'), + }, +} + + +class RPCMethods: + + def __init__(self, eth_tester=None, api_endpoints=None): + if eth_tester is None: + self.client = EthereumTester() + else: + self.client = eth_tester + + if api_endpoints is None: + self.api_endpoints = API_ENDPOINTS + else: + self.api_endpoints = api_endpoints + + def __getattr__(self, item): + namespace, _, endpoint = item.partition('_') + try: + delegator = self.api_endpoints[namespace][endpoint] + try: + return lambda *args, **kwargs: delegator(self.client, *args, **kwargs) + except NotImplementedError: + return None + except KeyError: + return super().__getattribute__(item) diff --git a/eth_tester_rpc/server.py b/eth_tester_rpc/server.py new file mode 100644 index 0000000..c8af17f --- /dev/null +++ b/eth_tester_rpc/server.py @@ -0,0 +1,97 @@ +import json + +from jsonrpc import ( + JSONRPCResponseManager, + dispatcher, +) +from werkzeug.wrappers import ( + Request, + Response, +) + +from .rpc import RPCMethods +from .utils.compat_threading import threading + + +RESPONSE_HEADERS = { + "Access-Control-Allow-Headers": "Origin, X-Requested-With, Content-Type, Accept", + "Access-Control-Allow-Origin": "*", +} + + +def get_application(): + # When calling server.register_function, first wrap the function in a mutex. + # This has the effect of serializing all RPC calls. Although we use a + # multithreaded HTTP server, the EVM itself is not thread-safe, so we must take + # care when interacting with it. + rpc_methods = RPCMethods() + evm_lock = threading.Lock() + + def with_lock(rpc_fn): + # @functools.wraps(rpc_fn) + def inner(*args, **kwargs): + evm_lock.acquire() + try: + return rpc_fn(*args, **kwargs) + finally: + evm_lock.release() + + return inner + + def add_method_with_lock(rpc_fn, *args, **kwargs): + rpc_fn_with_lock = with_lock(rpc_fn) + return dispatcher.add_method(rpc_fn_with_lock, *args, **kwargs) + + add_method_with_lock(rpc_methods.eth_coinbase, 'eth_coinbase') + add_method_with_lock(rpc_methods.eth_accounts, 'eth_accounts') + add_method_with_lock(rpc_methods.eth_gasPrice, 'eth_gasPrice') + add_method_with_lock(rpc_methods.eth_blockNumber, 'eth_blockNumber') + add_method_with_lock(rpc_methods.eth_estimateGas, 'eth_estimateGas') + add_method_with_lock(rpc_methods.eth_call, 'eth_call') + add_method_with_lock(rpc_methods.eth_sendTransaction, 'eth_sendTransaction') + add_method_with_lock(rpc_methods.eth_sendRawTransaction, 'eth_sendRawTransaction') + add_method_with_lock(rpc_methods.eth_getCompilers, 'eth_getCompilers') + add_method_with_lock(rpc_methods.eth_compileSolidity, 'eth_compileSolidity') + add_method_with_lock(rpc_methods.eth_getCode, 'eth_getCode') + add_method_with_lock(rpc_methods.eth_getBalance, 'eth_getBalance') + add_method_with_lock(rpc_methods.eth_getTransactionCount, 'eth_getTransactionCount') + add_method_with_lock(rpc_methods.eth_getTransactionByHash, 'eth_getTransactionByHash') + add_method_with_lock(rpc_methods.eth_getTransactionReceipt, 'eth_getTransactionReceipt') + add_method_with_lock(rpc_methods.eth_getBlockByHash, 'eth_getBlockByHash') + add_method_with_lock(rpc_methods.eth_getBlockByNumber, 'eth_getBlockByNumber') + add_method_with_lock(rpc_methods.eth_newBlockFilter, 'eth_newBlockFilter') + add_method_with_lock(rpc_methods.eth_newPendingTransactionFilter, + 'eth_newPendingTransactionFilter') + add_method_with_lock(rpc_methods.eth_newFilter, 'eth_newFilter') + add_method_with_lock(rpc_methods.eth_getFilterChanges, 'eth_getFilterChanges') + add_method_with_lock(rpc_methods.eth_getFilterLogs, 'eth_getFilterLogs') + add_method_with_lock(rpc_methods.eth_uninstallFilter, 'eth_uninstallFilter') + add_method_with_lock(rpc_methods.eth_protocolVersion, 'eth_protocolVersion') + add_method_with_lock(rpc_methods.eth_syncing, 'eth_syncing') + add_method_with_lock(rpc_methods.eth_mining, 'eth_mining') + add_method_with_lock(rpc_methods.web3_sha3, 'web3_sha3') + add_method_with_lock(rpc_methods.web3_clientVersion, 'web3_clientVersion') + add_method_with_lock(rpc_methods.net_version, 'net_version') + add_method_with_lock(rpc_methods.net_listening, 'net_listening') + add_method_with_lock(rpc_methods.net_peerCount, 'net_peerCount') + add_method_with_lock(rpc_methods.evm_snapshot, 'evm_snapshot') + add_method_with_lock(rpc_methods.evm_revert, 'evm_revert') + add_method_with_lock(rpc_methods.evm_mine, 'evm_mine') + add_method_with_lock(rpc_methods.testing_timeTravel, 'testing_timeTravel') + + @Request.application + def application(request): + response = JSONRPCResponseManager.handle( + request.data, + dispatcher, + ) + response = Response( + json.dumps(response.data), + headers=RESPONSE_HEADERS, + mimetype='application/json', + ) + return response + + application.rpc_methods = rpc_methods + + return application diff --git a/eth_tester_rpc/utils/__init__.py b/eth_tester_rpc/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/eth_tester_rpc/utils/compat_threading/__init__.py b/eth_tester_rpc/utils/compat_threading/__init__.py new file mode 100644 index 0000000..21c1ff8 --- /dev/null +++ b/eth_tester_rpc/utils/compat_threading/__init__.py @@ -0,0 +1,37 @@ +import os + + +def get_threading_backend(): + if 'TESTRPC_THREADING_BACKEND' in os.environ: + return os.environ['TESTRPC_THREADING_BACKEND'] + elif 'THREADING_BACKEND' in os.environ: + return os.environ['THREADING_BACKEND'] + else: + return 'stdlib' + + +THREADING_BACKEND = get_threading_backend() + + +if THREADING_BACKEND == 'stdlib': + from .compat_stdlib import ( # noqa + Timeout, + spawn, + sleep, + subprocess, + socket, + threading, + make_server, + ) +elif THREADING_BACKEND == 'gevent': + from .compat_gevent import ( # noqa + Timeout, + spawn, + sleep, + subprocess, + socket, + threading, + make_server, + ) +else: + raise ValueError("Unsupported threading backend. Must be one of 'gevent' or 'stdlib'") diff --git a/eth_tester_rpc/utils/compat_threading/compat_gevent.py b/eth_tester_rpc/utils/compat_threading/compat_gevent.py new file mode 100644 index 0000000..99bc120 --- /dev/null +++ b/eth_tester_rpc/utils/compat_threading/compat_gevent.py @@ -0,0 +1,23 @@ +import gevent +from gevent.pywsgi import ( # noqa: F401 + WSGIServer, +) +from gevent import ( # noqa; F401 + subprocess, + threading, + socket, +) + + +sleep = gevent.sleep +spawn = gevent.spawn + + +class Timeout(gevent.Timeout): + def check(self): + pass + + +def make_server(host, port, application, *args, **kwargs): + server = WSGIServer((host, port), application, *args, **kwargs) + return server diff --git a/eth_tester_rpc/utils/compat_threading/compat_stdlib.py b/eth_tester_rpc/utils/compat_threading/compat_stdlib.py new file mode 100644 index 0000000..6210a99 --- /dev/null +++ b/eth_tester_rpc/utils/compat_threading/compat_stdlib.py @@ -0,0 +1,103 @@ +""" +A minimal implementation of the various gevent APIs used within this codebase. +""" +import time +import threading +import socket # noqa: F401 +import subprocess # noqa: F401 +from wsgiref.simple_server import make_server # noqa: F401 + + +sleep = time.sleep + + +class Timeout(Exception): + """ + A limited subset of the `gevent.Timeout` context manager. + """ + seconds = None + exception = None + begun_at = None + is_running = None + + def __init__(self, seconds=None, exception=None, *args, **kwargs): + self.seconds = seconds + self.exception = exception + + def __enter__(self): + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + return False + + def __str__(self): + if self.seconds is None: + return '' + return "{0} seconds".format(self.seconds) + + @property + def expire_at(self): + if self.seconds is None: + raise ValueError("Timeouts with `seconds == None` do not have an expiration time") + elif self.begun_at is None: + raise ValueError("Timeout has not been started") + return self.begun_at + self.seconds + + def start(self): + if self.is_running is not None: + raise ValueError("Timeout has already been started") + self.begun_at = time.time() + self.is_running = True + + def check(self): + if self.is_running is None: + raise ValueError("Timeout has not been started") + elif self.is_running is False: + raise ValueError("Timeout has already been cancelled") + elif self.seconds is None: + return + elif time.time() > self.expire_at: + self.is_running = False + if isinstance(self.exception, type): + raise self.exception(str(self)) + elif isinstance(self.exception, Exception): + raise self.exception + else: + raise self + + def cancel(self): + self.is_running = False + + +class empty(object): + pass + + +class ThreadWithReturn(threading.Thread): + def __init__(self, target=None, args=None, kwargs=None): + super(ThreadWithReturn, self).__init__(target=target, args=args, kwargs=kwargs) + self.target = target + self.args = args + self.kwargs = kwargs + + def run(self): + self._return = self.target(*self.args, **self.kwargs) + + def get(self, timeout=None): + self.join(timeout) + try: + return self._return + except AttributeError: + raise RuntimeError("Something went wrong. No `_return` property was set") + + +def spawn(target, *args, **kwargs): + thread = ThreadWithReturn( + target=target, + args=args, + kwargs=kwargs, + ) + thread.daemon = True + thread.start() + return thread diff --git a/eth_tester_rpc/utils/formatters.py b/eth_tester_rpc/utils/formatters.py new file mode 100644 index 0000000..ea30a58 --- /dev/null +++ b/eth_tester_rpc/utils/formatters.py @@ -0,0 +1,11 @@ +from .toolz import ( + curry, +) + + +@curry +def apply_formatter_if(condition, formatter, value): + if condition(value): + return formatter(value) + else: + return value diff --git a/eth_tester_rpc/utils/toolz/__init__.py b/eth_tester_rpc/utils/toolz/__init__.py new file mode 100644 index 0000000..20589bd --- /dev/null +++ b/eth_tester_rpc/utils/toolz/__init__.py @@ -0,0 +1,12 @@ +try: + from cytoolz import ( + compose, + curry, + excepts, + ) +except ImportError: + from toolz import ( + compose, + curry, + excepts, + ) diff --git a/eth_tester_rpc/utils/toolz/curried.py b/eth_tester_rpc/utils/toolz/curried.py new file mode 100644 index 0000000..30c4cc9 --- /dev/null +++ b/eth_tester_rpc/utils/toolz/curried.py @@ -0,0 +1,4 @@ +try: + pass +except ImportError: + pass diff --git a/setup.py b/setup.py index 99618aa..2771152 100644 --- a/setup.py +++ b/setup.py @@ -26,6 +26,9 @@ "twine", "ipython", ], + 'gevent': [ + "gevent>=1.1.1,<1.2.0", + ] } extras_require['dev'] = ( @@ -36,22 +39,28 @@ ) setup( - name='', + name='eth-teser-rpc', # *IMPORTANT*: Don't manually change the version here. Use `make bump`, as described in readme version='0.1.0-alpha.0', - description=""": """, + description="""Python TestRPC for ethereun""", long_description_markdown_filename='README.md', - author='Jason Carver', + author='voith', author_email='ethcalibur+pip@gmail.com', - url='https://github.com/ethereum/', + url='https://github.com/voith/eth-teser-rpc', include_package_data=True, install_requires=[ "eth-utils>=1,<2", + "toolz>=0.9.0,<1.0.0;implementation_name=='pypy'", + "cytoolz>=0.9.0,<1.0.0;implementation_name=='cpython'", + "eth-tester[py-evm]==0.1.0b30", + 'json-rpc>=1.10.3', + 'Werkzeug>=0.11.10', + 'click>=6.6', ], setup_requires=['setuptools-markdown'], python_requires='>=3.5, <4', extras_require=extras_require, - py_modules=[''], + py_modules=['eth_tester_rpc'], license="MIT", zip_safe=False, keywords='ethereum',