diff --git a/lib/coins.py b/lib/coins.py index 6537e74..ec84b96 100644 --- a/lib/coins.py +++ b/lib/coins.py @@ -13,9 +13,10 @@ necessary for appropriate handling. from decimal import Decimal import inspect +import struct import sys -from lib.hash import Base58, hash160, double_sha256 +from lib.hash import Base58, hash160, double_sha256, hash_to_str from lib.script import ScriptPubKey from lib.tx import Deserializer @@ -31,6 +32,7 @@ class Coin(object): HEADER_LEN = 80 DEFAULT_RPC_PORT = 8332 VALUE_PER_COIN = 100000000 + CHUNK_SIZE=2016 @staticmethod def coin_classes(): @@ -168,6 +170,21 @@ class Coin(object): ''' return Decimal(value) / cls.VALUE_PER_COIN + @classmethod + def electrum_header(cls, header, height): + version, = struct.unpack(' self.height + len(self.headers): - raise Exception('no header information for height {:,d}' - .format(height)) - header = self.read_headers(self.height, 1) - unpack = struct.unpack - version, = unpack('= 0: + return param + raise RPCError('param should be a non-negative integer: {}' + .format(param)) + + @classmethod + def extract_hash168(cls, params): + if len(params) == 1: + return cls.hash168_from_param(params[0]) + raise RPCError('params should contain a single address: {}' + .format(params)) + + @classmethod + def extract_non_negative_integer(cls, params): + if len(params) == 1: + return cls.non_negative_integer_from_param(params[0]) + raise RPCError('params should contain a non-negative integer: {}' + .format(params)) + + @classmethod + def require_empty_params(cls, params): + if params: + raise RPCError('params should be empty: {}'.format(params)) + + @classmethod + def init(cls, block_processor, coin): + cls.BLOCK_PROCESSOR = block_processor + cls.COIN = coin + + @classmethod + def height(cls): + '''Return the current height.''' + return cls.BLOCK_PROCESSOR.height + + @classmethod + def electrum_header(cls, height=None): + '''Return the binary header at the given height.''' + if not 0 <= height <= cls.height(): + raise RPCError('height {:,d} out of range'.format(height)) + header = cls.BLOCK_PROCESSOR.read_headers(height, 1) + return cls.COIN.electrum_header(header, height) + + @classmethod + def current_electrum_header(cls): + '''Used as response to a headers subscription request.''' + return cls.electrum_header(cls.height()) + + @classmethod + def notify(cls, height, touched): + '''Notify electrum clients about height changes and touched + addresses.''' + headers_payload = json_notification( + 'blockchain.headers.subscribe', + (cls.electrum_header(height), ), + ) + height_payload = json_notification( + 'blockchain.numblocks.subscribe', + (height, ), + ) + for session in cls.SESSIONS: + if height != session.notified_height: + session.notified_height = height + if session.subscribe_headers: + session.json_send(headers_payload) + if session.subscribe_height: + session.json_send(height_payload) + + for hash168 in session.hash168s.intersection(touched): + payload = json_notification('blockchain.address.subscribe', + (Base58.encode_check(hash168), )) + session.json_send(payload) class ElectrumX(JSONRPC): @@ -122,60 +222,138 @@ class ElectrumX(JSONRPC): super().__init__(controller) self.daemon = daemon self.env = env - self.addresses = set() - self.subscribe_headers = False + self.hash168s = set() + rpcs = [( + 'blockchain', + 'address.get_balance address.get_history address.get_mempool ' + 'address.get_proof address.listunspent address.subscribe ' + 'block.get_header block.get_chunk estimatefee headers.subscribe ' + 'numblocks.subscribe relayfee transaction.broadcast ' + 'transaction.get transaction.get_merkle utxo.get_address'), + ( + 'server', + 'banner donation_address peers.subscribe version'), + ] + self.handlers = {'.'.join([prefix, suffix]): + getattr(self.__class__, suffix.replace('.', '_')) + for prefix, suffixes in rpcs + for suffix in suffixes.split()} - def params_to_hash168(self, params): - if len(params) != 1: - raise Error(Error.BAD_REQUEST, - 'params should contain a single address') - address = params[0] - try: - return self.env.coin.address_to_hash168(address) - except: - raise Error(Error.BAD_REQUEST, - 'invalid address: {}'.format(address)) + @classmethod + def watched_address_count(cls): + return sum(len(session.hash168s) for session in self.SESSIONS + if isinstance(session, cls)) - async def handle_blockchain_address_get_history(self, params): - hash168 = self.params_to_hash168(params) + # --- blockchain commands + + async def address_get_balance(self, params): + hash168 = self.extract_hash168(params) + return self.controller.get_balance(hash168) + + async def address_get_history(self, params): + hash168 = self.extract_hash168(params) return self.controller.get_history(hash168) - async def handle_blockchain_address_subscribe(self, params): - hash168 = self.params_to_hash168(params) + async def address_get_mempool(self, params): + hash168 = self.extract_hash168(params) + raise RPCError('get_mempool is not yet implemented') + + async def address_get_proof(self, params): + hash168 = self.extract_hash168(params) + raise RPCError('get_proof is not yet implemented') + + async def address_listunspent(self, params): + hash168 = self.extract_hash168(params) + return self.controller.list_unspent(hash168) + + async def address_subscribe(self, params): + hash168 = self.extract_hash168(params) + self.hash168s.add(hash168) status = self.controller.address_status(hash168) return status.hex() if status else None - async def handle_blockchain_estimatefee(self, params): - result = await self.daemon.send_single('estimatefee', params) - return result + async def block_get_chunk(self, params): + index = self.extract_non_negative_integer(params) + return self.controller.get_chunk(index) - async def handle_blockchain_headers_subscribe(self, params): + async def block_get_header(self, params): + height = self.extract_non_negative_integer(params) + return self.electrum_header(height) + + async def estimatefee(self, params): + return await self.daemon.estimatefee(params) + + async def headers_subscribe(self, params): + self.require_empty_params(params) self.subscribe_headers = True - return self.controller.get_current_header() + return self.current_electrum_header() - async def handle_blockchain_relayfee(self, params): + async def numblocks_subscribe(self, params): + self.require_empty_params(params) + self.subscribe_height = True + return self.height() + + async def relayfee(self, params): '''The minimum fee a low-priority tx must pay in order to be accepted - to this daemon's memory pool. + to the daemon's memory pool.''' + self.require_empty_params(params) + return await self.daemon.relayfee() + + async def transaction_broadcast(self, params): + '''Pass through the parameters to the daemon. + + An ugly API: current Electrum clients only pass the raw + transaction in hex and expect error messages to be returned in + the result field. And the server shouldn't be doing the client's + user interface job here. ''' - net_info = await self.daemon.send_single('getnetworkinfo') - return net_info['relayfee'] + try: + tx_hash = await self.daemon.sendrawtransaction(params) + self.logger.info('sent tx: {}'.format(tx_hash)) + return tx_hash + except DaemonError as e: + errors = e.args[0] + error = errors[0] + message = error['message'] + self.logger.info('sendrawtransaction: {}'.format(message)) + if 'non-mandatory-script-verify-flag' in message: + return ( + 'Your client produced a transaction that is not accepted ' + 'by the network any more. Please upgrade to Electrum ' + '2.5.1 or newer.' + ) - async def handle_blockchain_transaction_get(self, params): - if len(params) != 1: - raise Error(Error.BAD_REQUEST, - 'params should contain a transaction hash') - tx_hash = params[0] - return await self.daemon.send_single('getrawtransaction', (tx_hash, 0)) + return ( + 'The transaction was rejected by network rules. ({})\n[{}]' + .format(message, params[0]) + ) - async def handle_blockchain_transaction_get_merkle(self, params): - if len(params) != 2: - raise Error(Error.BAD_REQUEST, - 'params should contain a transaction hash and height') - tx_hash, height = params - return await self.controller.get_merkle(tx_hash, height) + async def transaction_get(self, params): + '''Return the serialized raw transaction.''' + # For some reason Electrum passes a height. Don't require it + # in anticipation it might be dropped in the future. + if 1 <= len(params) <= 2: + tx_hash = self.tx_hash_from_param(params[0]) + return await self.daemon.getrawtransaction(tx_hash) - async def handle_server_banner(self, params): + raise RPCError('params wrong length: {}'.format(params)) + + async def transaction_get_merkle(self, params): + if len(params) == 2: + tx_hash = self.tx_hash_from_param(params[0]) + height = self.non_negative_integer_from_param(params[1]) + return await self.controller.get_merkle(tx_hash, height) + + raise RPCError('params should contain a transaction hash and height') + + async def utxo_get_address(self, params): + pass # TODO + + # --- server commands + + async def banner(self, params): '''Return the server banner.''' + self.require_empty_params(params) banner = 'Welcome to Electrum!' if self.env.banner_file: try: @@ -186,23 +364,25 @@ class ElectrumX(JSONRPC): .format(self.env.banner_file, e)) return banner - async def handle_server_donation_address(self, params): + async def donation_address(self, params): '''Return the donation address as a string. If none is specified return the empty string. ''' + self.require_empty_params(params) return self.env.donation_address - async def handle_server_peers_subscribe(self, params): + async def peers_subscribe(self, params): '''Returns the peer (ip, host, ports) tuples. Despite the name electrum-server does not treat this as a subscription. ''' + self.require_empty_params(params) peers = self.controller.get_peers() return tuple(peers.values()) - async def handle_server_version(self, params): + async def version(self, params): '''Return the server version as a string.''' return VERSION @@ -210,24 +390,28 @@ class ElectrumX(JSONRPC): class LocalRPC(JSONRPC): '''A local TCP RPC server for querying status.''' - async def handle_getinfo(self, params): + def __init__(self): + super().__init__() + cmds = 'getinfo sessions numsessions peers numpeers'.split() + self.handlers = {cmd: getattr(self.__class__, cmd) for cmd in cmds} + + async def getinfo(self, params): return { - 'blocks': self.controller.height(), + 'blocks': self.height(), 'peers': len(self.controller.get_peers()), - 'sessions': len(self.controller.sessions), - 'watched': sum(len(s.addresses) for s in self.controller.sessions - if isinstance(s, ElectrumX)), + 'sessions': len(self.SESSIONS), + 'watched': ElectrumX.watched_address_count(), 'cached': 0, } - async def handle_sessions(self, params): + async def sessions(self, params): return [] - async def handle_numsessions(self, params): - return len(self.controller.sessions) + async def numsessions(self, params): + return len(self.SESSIONS) - async def handle_peers(self, params): + async def peers(self, params): return tuple(self.controller.get_peers().keys()) - async def handle_numpeers(self, params): + async def numpeers(self, params): return len(self.controller.get_peers())