electrum-obelisk

Electrum server using libbitcoin as its backend
git clone https://git.parazyd.org/electrum-obelisk
Log | Files | Refs | README | LICENSE

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)