protocol.py (14008B)
1 #!/usr/bin/env python3 2 # electrum-obelisk 3 # Copyright (C) 2020-2021 Ivan J. <parazyd@dyne.org> 4 # Copyright (C) 2018-2020 Chris Belcher (MIT License) 5 # 6 # This program is free software: you can redistribute it and/or modify 7 # it under the terms of the GNU Affero General Public License as published by 8 # the Free Software Foundation, either version 3 of the License, or 9 # (at your option) any later version. 10 # 11 # This program is distributed in the hope that it will be useful, 12 # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 # GNU Affero General Public License for more details. 15 # 16 # You should have received a copy of the GNU Affero General Public License 17 # along with this program. If not, see <http://www.gnu.org/licenses/>. 18 """ non-strict electrum protocol implementation """ 19 from struct import unpack 20 from binascii import unhexlify 21 from threading import Thread 22 23 import zmq 24 25 from electrumobelisk.bx import bx_json, bx_raw, bs_version 26 from electrumobelisk.hash import sha256 27 from electrumobelisk.helpers import safe_hexlify 28 from electrumobelisk.merkle import (merkle_branch_for_tx_hash, 29 merkle_branch_for_tx_pos) 30 from electrumobelisk.query import (q_fetch_header_by_height, 31 q_fetch_headers_by_height_count, 32 q_fetch_tx_by_txid) 33 34 35 VERSION = '0.0' 36 SERVER_PROTO_MIN = '1.4' 37 SERVER_PROTO_MAX = '1.4.2' 38 DONATION_ADDR = 'bc1q7an9p5pz6pjwjk4r48zke2yfaevafzpglg26mz' 39 __certfile__ = 'certs/cert.crt' 40 __keyfile__ = 'certs/cert.key' 41 42 BANNER = """ 43 Welcome to electrum-obelisk 44 45 "Tools for The People" 46 %s 47 electrum-obelisk version: %s 48 49 electrum-obelisk is a server that uses libbitcoin-server as its backend. 50 Source code can be found at: https://github.com/parazyd/electrum-obelisk 51 52 Please consider donating: %s 53 54 """ % (bs_version(), VERSION, DONATION_ADDR) 55 56 57 def fetch_scripthash_history(scripthash): 58 """ Fetch transaction history for given scripthash and return a list """ 59 # BUG: fetch-history sometimes returns duplicates, so below in the 60 # loop it is necessary to check if we have already added the element. 61 hist = [] 62 data = bx_json(['fetch-history', scripthash]) 63 for i in data['transfers']: 64 # print(data) 65 if 'received' in i: 66 entr = {'height': int(i['received']['height']), 67 'tx_hash': i['received']['hash']} 68 if entr not in hist: 69 hist.append(entr) 70 if 'spent' in i: 71 entr = {'height': int(i['spent']['height']), 72 'tx_hash': i['spent']['hash']} 73 if entr not in hist: 74 hist.append(entr) 75 return hist 76 77 78 def electrum_scripthash_status(hist): 79 """ Encode scripthash status into the protocol format """ 80 if len(hist) < 1: 81 return None 82 ret = "" 83 for i in hist: 84 ret += '%s:%d:' % (i['tx_hash'], i['height']) 85 return safe_hexlify(sha256(bytes(ret.encode('ascii')))) 86 87 88 class ElectrumProtocol(): 89 """ 90 Class which implements the Electrum protocol for a single connection. 91 It does not handle the actual sockets, which could be any combination 92 if blocking/non-blocking, asyncio, twisted, etc. 93 This class may be instantized multiple times if the server accepts 94 multiple client connections at once. 95 """ 96 def __init__(self, log, chain, endpoints): 97 self.log = log 98 self.send_reply_fun = None 99 self.version_called = False 100 self.subscribed_to_headers = False 101 self.subscribed_scripthash = {} 102 self.endpoints = endpoints 103 104 if chain == 'mainnet': 105 self.genesis = \ 106 '000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f' 107 elif chain == 'regtest': 108 self.genesis = \ 109 '0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206' 110 elif chain == 'testnet': 111 self.genesis = \ 112 '000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943' 113 else: 114 raise ValueError('Invalid chain "%s"' % chain) 115 116 def _send_response(self, query, result): 117 response = {'jsonrpc': '2.0', 'result': result, 'id': query['id']} 118 self.send_reply_fun(response) 119 120 def _send_update(self, update): 121 update['jsonrpc'] = '2.0' 122 self.send_reply_fun(update) 123 124 def _send_error(self, nid, error): 125 payload = {'error': error, 'jsonrpc': '2.0', 'id': nid} 126 self.send_reply_fun(payload) 127 128 def set_send_reply_fun(self, send_reply_fun): 129 """ Function to set a reply function """ 130 self.send_reply_fun = send_reply_fun 131 132 def on_disconnect(self): 133 """ Used to clean up when client is disconnected """ 134 self.log.debug('on_disconnect') 135 self.subscribed_to_headers = False 136 self.version_called = False 137 self.subscribed_scripthash = {} 138 139 def _subscribe_to_blocks(self): 140 if not self.subscribed_to_headers: 141 context = zmq.Context() 142 socket = context.socket(zmq.SUB) # pylint: disable=E1101 143 socket.connect(self.endpoints['query']) 144 socket.setsockopt_string(zmq.SUBSCRIBE, '') # pylint: disable=E1101 145 socket.setsockopt(zmq.TCP_KEEPALIVE, 1) # pylint: disable=E1101 146 self.subscribed_to_headers = True 147 while self.subscribed_to_headers: 148 _ = unpack('<H', socket.recv())[0] 149 height = unpack('<I', socket.recv())[0] 150 block = socket.recv() 151 header = {'hex': safe_hexlify(block), 'height': int(height)} 152 update = {'method': 'blockchain.headers.subscribe', 153 'params': header} 154 self._send_update(update) 155 156 def _subscribe_to_scripthash(self, scripthash): 157 # TODO: subscribe_to_scripthash() 158 # Append to hist list from self.subscribed_scripthash and send 159 # update notification/method. 160 return 161 162 # https://electrumx-spesmilo.readthedocs.io/en/latest/protocol-methods.html 163 def handle_query(self, query): 164 """ Electrum protocol method handlers """ 165 if 'method' not in query: 166 raise IOError('Bad client query, no "method"') 167 method = query['method'] 168 169 if method == 'blockchain.block.header': 170 # TODO: cp_height 171 height = int(query['params'][0]) 172 header = q_fetch_header_by_height(self.endpoints['query'], height) 173 if not header: 174 error = {'message': 'block at height %d not found' % height} 175 self._send_error(query['id'], error) 176 return 177 self._send_response(query, safe_hexlify(header)) 178 179 elif method == 'blockchain.block.headers': 180 # TODO: cp_height 181 # Electrum doesn't allow max_chunk_size to be less than 2016. 182 # gopher://bitreich.org/9/memecache/convenience-store.mkv 183 max_chunk_size = 2016 184 start_height = query['params'][0] 185 count = int(query['params'][1]) 186 count = min(count, max_chunk_size) 187 headers = q_fetch_headers_by_height_count(self.endpoints['query'], 188 start_height, count) 189 self._send_response(query, {'hex': safe_hexlify(headers), 190 'count': len(headers)//80, 191 'max': max_chunk_size}) 192 193 elif method == 'blockchain.estimatefee': 194 self._send_response(query, -1) 195 196 elif method == 'blockchain.headers.subscribe': 197 if not self.subscribed_to_headers: 198 Thread(target=self._subscribe_to_blocks).start() 199 height = int(bx_raw(['fetch-height'])) 200 header = q_fetch_header_by_height(self.endpoints['query'], height) 201 self._send_response(query, {'height': height, 202 'hex': safe_hexlify(header)}) 203 204 elif method == 'blockchain.relayfee': 205 self._send_response(query, 0.00001) 206 207 elif method == 'blockchain.scripthash.get_balance': 208 self._send_error(query['id'], {'message': 'Not implemented'}) 209 210 elif method == 'blockchain.scripthash.get_history': 211 # BUG: Either in Electrum or libbitcoin scripthash is reversed 212 scripthash = safe_hexlify(unhexlify(query['params'][0])[::-1]) 213 hist = fetch_scripthash_history(scripthash) 214 self._send_response(query, hist) 215 216 elif method == 'blockchain.scripthash.get_mempool': 217 # TODO: blockchain.scripthash.get_mempool 218 self._send_error(query['id'], {'message': 'Not implemented'}) 219 220 elif method == 'blockchain.scripthash.list_unspent': 221 # TODO: blockchain.scripthash.list_unspent 222 self._send_error(query['id'], {'message': 'Not implemented'}) 223 224 elif method == 'blockchain.scripthash.subscribe': 225 # BUG: Either in Electrum or libbitcoin scripthash is reversed 226 scripthash = safe_hexlify(unhexlify(query['params'][0])[::-1]) 227 hist = fetch_scripthash_history(scripthash) 228 self.subscribed_scripthash[scripthash] = (True, hist) 229 # TODO: Thread for watching 230 self._send_response(query, electrum_scripthash_status(hist)) 231 232 elif method == 'blockchain.scripthash.unsubscribe': 233 scripthash = query['params'][0] 234 if self.subscribed_scripthash.get(scripthash) is True: 235 self.subscribed_scripthash[scripthash] = False 236 self._send_response(query, True) 237 return 238 self._send_response(query, False) 239 240 elif method == 'blockchain.transaction.broadcast': 241 txhex = query['params'][0] 242 _tx = bx_json(['decode-tx', txhex])['transaction'] 243 self.log.info('Broadcasting txid %s', _tx['hash']) 244 bx_raw(['broadcast-tx', txhex]) 245 self._send_response(query, _tx['hash']) 246 247 elif method == 'blockchain.transaction.get': 248 txid = query['params'][0] 249 verbose = query['params'][1] if len(query['params']) > 1 else False 250 rawtx = q_fetch_tx_by_txid(self.endpoints['query'], txid) 251 if not rawtx: 252 error = {'message': 'txid %s not found' % txid} 253 self._send_error(query['id'], error) 254 return 255 _tx = safe_hexlify(rawtx) 256 if not verbose: 257 self._send_response(query, _tx) 258 return 259 # TODO: Implement with verbose=true 260 error = {'message': 'Not implemented with verbose=True'} 261 self._send_error(query['id'], error) 262 263 elif method == 'blockchain.transaction.get_merkle': 264 txid = query['params'][0] 265 height = query['params'][1] 266 branch, tx_pos = merkle_branch_for_tx_hash(height, txid) 267 if not branch or not tx_pos: 268 self._send_error(query['id'], {'message': 'txid not found'}) 269 self._send_response(query, {'merkle': branch, 'pos': int(tx_pos), 270 'block_height': int(height)}) 271 272 elif method == 'blockchain.transaction.id_from_pos': 273 height = query['params'][0] 274 tx_pos = int(query['params'][1]) 275 merkle = query['params'][2] if len(query['params']) > 2 else False 276 branch, txid = merkle_branch_for_tx_pos(height, tx_pos) 277 if not txid: 278 error = {'message': 'could not find txid'} 279 self._send_error(query['id'], error) 280 return 281 if not merkle: 282 self._send_response(query, txid) 283 return 284 self._send_response(query, {'tx_hash': txid, 'merkle': branch}) 285 286 elif method == 'mempool.get_fee_histogram': 287 # TODO: Implement mempool.get_fee_histogram 288 self._send_response(query, [[0, 0]]) 289 290 elif method == 'server.add_peer': 291 self._send_response(query, False) 292 293 elif method == 'server.banner': 294 self._send_response(query, BANNER) 295 296 elif method == 'server.donation_address': 297 self._send_response(query, DONATION_ADDR) 298 299 elif method == 'server.features': 300 ret = { 301 'genesis_hash': self.genesis, 302 'hosts': { 303 'localhost': {'tcp_port': None, 'ssl_port': 50002}, 304 }, 305 'protocol_max': SERVER_PROTO_MAX, 306 'protocol_min': SERVER_PROTO_MIN, 307 'pruning': None, 308 'server_version': 'electrum-obelisk %s' % VERSION, 309 'hash_function': 'sha256', 310 } 311 self._send_response(query, ret) 312 313 elif method == 'server.peers.subscribe': 314 # No peers to report 315 self._send_response(query, []) 316 317 elif method == 'server.ping': 318 self._send_response(query, None) 319 320 elif method == 'server.version': 321 if self.version_called: 322 self.log.warning('Got a subsequent %s call', method) 323 return 324 client_ver = query['params'][1] 325 if isinstance(client_ver, list): 326 client_min, client_max = client_ver[0], client_ver[1] 327 else: 328 client_min = client_max = client_ver 329 version = min(client_max, SERVER_PROTO_MAX) 330 if version < max(client_min, SERVER_PROTO_MIN): 331 self.log.error("Client protocol version %s not supported.", 332 str(client_ver)) 333 raise ConnectionRefusedError() 334 # self.version_called = True 335 self._send_response(query, ['electrum-obelisk %s' % VERSION, 336 version]) 337 338 else: 339 self.log.error('BUG: Not handling method: "%s" query=%s', 340 method, query)