obelisk

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

protocol.py (25935B)


      1 #!/usr/bin/env python3
      2 # Copyright (C) 2020-2021 Ivan J. <parazyd@dyne.org>
      3 #
      4 # This file is part of obelisk
      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 version 3
      8 # as published by the Free Software Foundation.
      9 #
     10 # This program is distributed in the hope that it will be useful,
     11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
     12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     13 # GNU Affero General Public License for more details.
     14 #
     15 # You should have received a copy of the GNU Affero General Public License
     16 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
     17 """Implementation of the Electrum protocol as found on
     18 https://electrumx-spesmilo.readthedocs.io/en/latest/protocol-methods.html
     19 """
     20 import asyncio
     21 import json
     22 from binascii import unhexlify
     23 
     24 from electrumobelisk.errors import ERRORS
     25 from electrumobelisk.merkle import merkle_branch
     26 from electrumobelisk.util import (
     27     bh2u,
     28     block_to_header,
     29     is_boolean,
     30     is_hash256_str,
     31     is_hex_str,
     32     is_non_negative_integer,
     33     safe_hexlify,
     34     sha256,
     35     double_sha256,
     36     hash_to_hex_str,
     37 )
     38 from electrumobelisk.zeromq import Client
     39 
     40 VERSION = "0.0"
     41 SERVER_PROTO_MIN = "1.4"
     42 SERVER_PROTO_MAX = "1.4.2"
     43 DONATION_ADDR = "bc1q7an9p5pz6pjwjk4r48zke2yfaevafzpglg26mz"
     44 
     45 BANNER = ("""
     46 Welcome to obelisk
     47 
     48 "Tools for the people"
     49 
     50 obelisk is a server that uses libbitcoin-server as its backend.
     51 Source code can be found at: https://github.com/parazyd/obelisk
     52 
     53 Please consider donating: %s
     54 """ % DONATION_ADDR)
     55 
     56 
     57 class ElectrumProtocol(asyncio.Protocol):  # pylint: disable=R0904,R0902
     58     """Class implementing the Electrum protocol, with async support"""
     59     def __init__(self, log, chain, endpoints, server_cfg):
     60         self.log = log
     61         self.stopped = False
     62         self.endpoints = endpoints
     63         self.server_cfg = server_cfg
     64         self.loop = asyncio.get_event_loop()
     65         # Consider renaming bx to something else
     66         self.bx = Client(log, endpoints, self.loop)
     67         self.block_queue = None
     68         # TODO: Clean up on client disconnect
     69         self.tasks = []
     70         self.sh_subscriptions = {}
     71 
     72         if chain == "mainnet":
     73             self.genesis = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"
     74         elif chain == "testnet":
     75             self.genesis = "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943"
     76         else:
     77             raise ValueError(f"Invalid chain '{chain}'")
     78 
     79         # Here we map available methods to their respective functions
     80         self.methodmap = {
     81             "blockchain.block.header": self.blockchain_block_header,
     82             "blockchain.block.headers": self.blockchain_block_headers,
     83             "blockchain.estimatefee": self.blockchain_estimatefee,
     84             "blockchain.headers.subscribe": self.blockchain_headers_subscribe,
     85             "blockchain.relayfee": self.blockchain_relayfee,
     86             "blockchain.scripthash.get_balance":
     87             self.blockchain_scripthash_get_balance,
     88             "blockchain.scripthash.get_history":
     89             self.blockchain_scripthash_get_history,
     90             "blockchain.scripthash.get_mempool":
     91             self.blockchain_scripthash_get_mempool,
     92             "blockchain.scripthash.listunspent":
     93             self.blockchain_scripthash_listunspent,
     94             "blockchain.scripthash.subscribe":
     95             self.blockchain_scripthash_subscribe,
     96             "blockchain.scripthash.unsubscribe":
     97             self.blockchain_scripthash_unsubscribe,
     98             "blockchain.transaction.broadcast":
     99             self.blockchain_transaction_broadcast,
    100             "blockchain.transaction.get": self.blockchain_transaction_get,
    101             "blockchain.transaction.get_merkle":
    102             self.blockchain_transaction_get_merkle,
    103             "blockchain.transaction.id_from_pos":
    104             self.blockchain_transaction_from_pos,
    105             "mempool.get_fee_histogram": self.mempool_get_fee_histogram,
    106             "server_add_peer": self.server_add_peer,
    107             "server.banner": self.server_banner,
    108             "server.donation_address": self.server_donation_address,
    109             "server.features": self.server_features,
    110             "server.peers.subscribe": self.server_peers_subscribe,
    111             "server.ping": self.server_ping,
    112             "server.version": self.server_version,
    113         }
    114 
    115     async def stop(self):
    116         """Destructor function"""
    117         self.log.debug("ElectrumProtocol.stop()")
    118         if self.bx:
    119             unsub_pool = []
    120             for i in self.sh_subscriptions:
    121                 self.log.debug("bx.unsubscribe %s", i)
    122                 unsub_pool.append(self.bx.unsubscribe_scripthash(i))
    123             await asyncio.gather(*unsub_pool, return_exceptions=True)
    124             await self.bx.stop()
    125 
    126         # idxs = []
    127         # for task in self.tasks:
    128         # idxs.append(self.tasks.index(task))
    129         # task.cancel()
    130         # for i in idxs:
    131         # del self.tasks[i]
    132 
    133         self.stopped = True
    134 
    135     async def recv(self, reader, writer):
    136         """Loop ran upon a connection which acts as a JSON-RPC handler"""
    137         recv_buf = bytearray()
    138         while not self.stopped:
    139             data = await reader.read(4096)
    140             if not data or len(data) == 0:
    141                 self.log.debug("Received EOF, disconnect")
    142                 # TODO: cancel asyncio tasks for this client here?
    143                 return
    144             recv_buf.extend(data)
    145             lb = recv_buf.find(b"\n")
    146             if lb == -1:
    147                 continue
    148             while lb != -1:
    149                 line = recv_buf[:lb].rstrip()
    150                 recv_buf = recv_buf[lb + 1:]
    151                 lb = recv_buf.find(b"\n")
    152                 try:
    153                     line = line.decode("utf-8")
    154                     query = json.loads(line)
    155                 except (UnicodeDecodeError, json.JSONDecodeError) as err:
    156                     self.log.debug("Got error: %s", repr(err))
    157                     break
    158                 self.log.debug("=> " + line)
    159                 await self.handle_query(writer, query)
    160 
    161     async def _send_notification(self, writer, method, params):
    162         """Send JSON-RPC notification to given writer"""
    163         response = {"jsonrpc": "2.0", "method": method, "params": params}
    164         self.log.debug("<= %s", response)
    165         writer.write(json.dumps(response).encode("utf-8") + b"\n")
    166         await writer.drain()
    167 
    168     async def _send_response(self, writer, result, nid):
    169         """Send successful JSON-RPC response to given writer"""
    170         response = {"jsonrpc": "2.0", "result": result, "id": nid}
    171         self.log.debug("<= %s", response)
    172         writer.write(json.dumps(response).encode("utf-8") + b"\n")
    173         await writer.drain()
    174 
    175     async def _send_error(self, writer, error, nid):
    176         """Send JSON-RPC error to given writer"""
    177         response = {"jsonrpc": "2.0", "error": error, "id": nid}
    178         self.log.debug("<= %s", response)
    179         writer.write(json.dumps(response).encode("utf-8") + b"\n")
    180         await writer.drain()
    181 
    182     async def _send_reply(self, writer, resp, query):
    183         """Wrap function for sending replies"""
    184         if "error" in resp:
    185             return await self._send_error(writer, resp["error"], query["id"])
    186         return await self._send_response(writer, resp["result"], query["id"])
    187 
    188     async def handle_query(self, writer, query):  # pylint: disable=R0915,R0912,R0911
    189         """Electrum protocol method handler mapper"""
    190         if "method" not in query:
    191             self.log.debug("No 'method' in query: %s", query)
    192             return
    193         if "id" not in query:
    194             self.log.debug("No 'id' in query: %s", query)
    195             return
    196 
    197         method = query["method"]
    198         func = self.methodmap.get(method)
    199         if not func:
    200             self.log.error("Unhandled method %s, query=%s", method, query)
    201             return await self._send_reply(writer, ERRORS["nomethod"], query)
    202         resp = await func(writer, query)
    203         return await self._send_reply(writer, resp, query)
    204 
    205     async def blockchain_block_header(self, writer, query):  # pylint: disable=W0613
    206         """Method: blockchain.block.header
    207         Return the block header at the given height.
    208         """
    209         if "params" not in query or len(query["params"]) < 1:
    210             return ERRORS["invalidparams"]
    211         # TODO: cp_height
    212         index = query["params"][0]
    213         cp_height = query["params"][1] if len(query["params"]) == 2 else 0
    214 
    215         if not is_non_negative_integer(index):
    216             return ERRORS["invalidparams"]
    217         if not is_non_negative_integer(cp_height):
    218             return ERRORS["invalidparams"]
    219 
    220         _ec, data = await self.bx.fetch_block_header(index)
    221         if _ec and _ec != 0:
    222             self.log.debug("Got error: %s", repr(_ec))
    223             return ERRORS["internalerror"]
    224         return {"result": safe_hexlify(data)}
    225 
    226     async def blockchain_block_headers(self, writer, query):  # pylint: disable=W0613
    227         """Method: blockchain.block.headers
    228         Return a concatenated chunk of block headers from the main chain.
    229         """
    230         if "params" not in query or len(query["params"]) < 2:
    231             return ERRORS["invalidparams"]
    232         # Electrum doesn't allow max_chunk_size to be less than 2016
    233         # gopher://bitreich.org/9/memecache/convenience-store.mkv
    234         # TODO: cp_height
    235         max_chunk_size = 2016
    236         start_height = query["params"][0]
    237         count = query["params"][1]
    238 
    239         if not is_non_negative_integer(start_height):
    240             return ERRORS["invalidparams"]
    241         if not is_non_negative_integer(count):
    242             return ERRORS["invalidparams"]
    243 
    244         count = min(count, max_chunk_size)
    245         headers = bytearray()
    246         for i in range(count):
    247             _ec, data = await self.bx.fetch_block_header(i)
    248             if _ec and _ec != 0:
    249                 self.log.debug("Got error: %s", repr(_ec))
    250                 return ERRORS["internalerror"]
    251             headers.extend(data)
    252 
    253         resp = {
    254             "hex": safe_hexlify(headers),
    255             "count": len(headers) // 80,
    256             "max": max_chunk_size,
    257         }
    258         return {"result": resp}
    259 
    260     async def blockchain_estimatefee(self, writer, query):  # pylint: disable=W0613
    261         """Method: blockchain.estimatefee
    262         Return the estimated transaction fee per kilobyte for a transaction
    263         to be confirmed within a certain number of blocks.
    264         """
    265         # TODO: Help wanted
    266         return {"result": -1}
    267 
    268     async def header_notifier(self, writer):
    269         self.block_queue = asyncio.Queue()
    270         await self.bx.subscribe_to_blocks(self.block_queue)
    271         while True:
    272             # item = (seq, height, block_data)
    273             item = await self.block_queue.get()
    274             if len(item) != 3:
    275                 self.log.debug("error: item from block queue len != 3")
    276                 continue
    277 
    278             header = block_to_header(item[2])
    279             params = [{"height": item[1], "hex": safe_hexlify(header)}]
    280             await self._send_notification(writer,
    281                                           "blockchain.headers.subscribe",
    282                                           params)
    283 
    284     async def blockchain_headers_subscribe(self, writer, query):  # pylint: disable=W0613
    285         """Method: blockchain.headers.subscribe
    286         Subscribe to receive block headers when a new block is found.
    287         """
    288         # Tip height and header are returned upon request
    289         _ec, height = await self.bx.fetch_last_height()
    290         if _ec and _ec != 0:
    291             self.log.debug("Got error: %s", repr(_ec))
    292             return ERRORS["internalerror"]
    293         _ec, tip_header = await self.bx.fetch_block_header(height)
    294         if _ec and _ec != 0:
    295             self.log.debug("Got error: %s", repr(_ec))
    296             return ERRORS["internalerror"]
    297 
    298         self.tasks.append(asyncio.create_task(self.header_notifier(writer)))
    299         ret = {"height": height, "hex": safe_hexlify(tip_header)}
    300         return {"result": ret}
    301 
    302     async def blockchain_relayfee(self, writer, query):  # pylint: disable=W0613
    303         """Method: blockchain.relayfee
    304         Return the minimum fee a low-priority transaction must pay in order
    305         to be accepted to the daemon’s memory pool.
    306         """
    307         # TODO: Help wanted
    308         return {"result": 0.00001}
    309 
    310     async def blockchain_scripthash_get_balance(self, writer, query):  # pylint: disable=W0613
    311         """Method: blockchain.scripthash.get_balance
    312         Return the confirmed and unconfirmed balances of a script hash.
    313         """
    314         if "params" not in query or len(query["params"]) != 1:
    315             return ERRORS["invalidparams"]
    316 
    317         if not is_hash256_str(query["params"][0]):
    318             return ERRORS["invalidparams"]
    319 
    320         _ec, data = await self.bx.fetch_balance(query["params"][0])
    321         if _ec and _ec != 0:
    322             self.log.debug("Got error: %s", repr(_ec))
    323             return ERRORS["internalerror"]
    324 
    325         # TODO: confirmed/unconfirmed, see what's happening in libbitcoin
    326         ret = {"confirmed": data, "unconfirmed": 0}
    327         return {"result": ret}
    328 
    329     async def blockchain_scripthash_get_history(self, writer, query):  # pylint: disable=W0613
    330         """Method: blockchain.scripthash.get_history
    331         Return the confirmed and unconfirmed history of a script hash.
    332         """
    333         if "params" not in query or len(query["params"]) != 1:
    334             return ERRORS["invalidparams"]
    335 
    336         if not is_hash256_str(query["params"][0]):
    337             return ERRORS["invalidparams"]
    338 
    339         _ec, data = await self.bx.fetch_history4(query["params"][0])
    340         if _ec and _ec != 0:
    341             self.log.debug("Got error: %s", repr(_ec))
    342             return ERRORS["internalerror"]
    343 
    344         self.log.debug("hist: %s", data)
    345         ret = []
    346         # TODO: mempool
    347         for i in data:
    348             if "received" in i:
    349                 ret.append({
    350                     "height": i["received"]["height"],
    351                     "tx_hash": hash_to_hex_str(i["received"]["hash"]),
    352                 })
    353             if "spent" in i:
    354                 ret.append({
    355                     "height": i["spent"]["height"],
    356                     "tx_hash": hash_to_hex_str(i["spent"]["hash"]),
    357                 })
    358 
    359         return {"result": ret}
    360 
    361     async def blockchain_scripthash_get_mempool(self, writer, query):  # pylint: disable=W0613
    362         """Method: blockchain.scripthash.get_mempool
    363         Return the unconfirmed transactions of a script hash.
    364         """
    365         return
    366 
    367     async def blockchain_scripthash_listunspent(self, writer, query):  # pylint: disable=W0613
    368         """Method: blockchain.scripthash.listunspent
    369         Return an ordered list of UTXOs sent to a script hash.
    370         """
    371         if "params" not in query or len(query["params"]) != 1:
    372             return ERRORS["invalidparams"]
    373 
    374         scripthash = query["params"][0]
    375         if not is_hash256_str(scripthash):
    376             return ERRORS["invalidparams"]
    377 
    378         _ec, utxo = await self.bx.fetch_utxo(scripthash)
    379         if _ec and _ec != 0:
    380             self.log.debug("Got error: %s", repr(_ec))
    381             return ERRORS["internalerror"]
    382 
    383         # TODO: Check mempool
    384         ret = []
    385         for i in utxo:
    386             rec = i["received"]
    387             ret.append({
    388                 "tx_pos": rec["index"],
    389                 "value": i["value"],
    390                 "tx_hash": hash_to_hex_str(rec["hash"]),
    391                 "height": rec["height"],
    392             })
    393         return {"result": ret}
    394 
    395     async def scripthash_notifier(self, writer, scripthash):
    396         # TODO: Figure out how this actually works
    397         _ec, sh_queue = await self.bx.subscribe_scripthash(scripthash)
    398         if _ec and _ec != 0:
    399             self.log.error("bx.subscribe_scripthash failed:", repr(_ec))
    400             return
    401 
    402         while True:
    403             # item = (seq, height, block_data)
    404             item = await sh_queue.get()
    405             self.log.debug("sh_subscription item: %s", item)
    406 
    407     async def blockchain_scripthash_subscribe(self, writer, query):  # pylint: disable=W0613
    408         """Method: blockchain.scripthash.subscribe
    409         Subscribe to a script hash.
    410         """
    411         if "params" not in query or len(query["params"]) != 1:
    412             return ERRORS["invalidparamas"]
    413 
    414         scripthash = query["params"][0]
    415         if not is_hash256_str(scripthash):
    416             return ERRORS["invalidparams"]
    417 
    418         _ec, history = await self.bx.fetch_history4(scripthash)
    419         if _ec and _ec != 0:
    420             return ERRORS["internalerror"]
    421 
    422         task = asyncio.create_task(self.scripthash_notifier(
    423             writer, scripthash))
    424         self.sh_subscriptions[scripthash] = {"task": task}
    425 
    426         if len(history) < 1:
    427             return {"result": None}
    428 
    429         # TODO: Check how history4 acts for mempool/unconfirmed
    430         status = []
    431         for i in history:
    432             if "received" in i:
    433                 status.append((
    434                     hash_to_hex_str(i["received"]["hash"]),
    435                     i["received"]["height"],
    436                 ))
    437             if "spent" in i:
    438                 status.append((
    439                     hash_to_hex_str(i["spent"]["hash"]),
    440                     i["spent"]["height"],
    441                 ))
    442 
    443         self.sh_subscriptions[scripthash]["status"] = status
    444         return {"result": ElectrumProtocol.__scripthash_status(status)}
    445 
    446     @staticmethod
    447     def __scripthash_status(status):
    448         concat = ""
    449         for txid, height in status:
    450             concat += txid + ":%d:" % height
    451         return bh2u(sha256(concat.encode("ascii")))
    452 
    453     async def blockchain_scripthash_unsubscribe(self, writer, query):  # pylint: disable=W0613
    454         """Method: blockchain.scripthash.unsubscribe
    455         Unsubscribe from a script hash, preventing future notifications
    456         if its status changes.
    457         """
    458         if "params" not in query or len(query["params"]) != 1:
    459             return ERRORS["invalidparams"]
    460 
    461         scripthash = query["params"][0]
    462         if not is_hash256_str(scripthash):
    463             return ERRORS["invalidparams"]
    464 
    465         if scripthash in self.sh_subscriptions:
    466             self.sh_subscriptions[scripthash]["task"].cancel()
    467             await self.bx.unsubscribe_scripthash(scripthash)
    468             del self.sh_subscriptions[scripthash]
    469             return {"result": True}
    470 
    471         return {"result": False}
    472 
    473     async def blockchain_transaction_broadcast(self, writer, query):  # pylint: disable=W0613
    474         """Method: blockchain.transaction.broadcast
    475         Broadcast a transaction to the network.
    476         """
    477         # Note: Not yet implemented in bs v4
    478         if "params" not in query or len(query["params"]) != 1:
    479             return ERRORS["invalidparams"]
    480 
    481         hextx = query["params"][0]
    482         if not is_hex_str(hextx):
    483             return ERRORS["invalidparams"]
    484 
    485         _ec, _ = await self.bx.broadcast_transaction(hextx)
    486         if _ec and _ec != 0:
    487             return ERRORS["internalerror"]
    488 
    489         rawtx = unhexlify(hextx)
    490         txid = double_sha256(rawtx)
    491         return {"result": hash_to_hex_str(txid)}
    492 
    493     async def blockchain_transaction_get(self, writer, query):  # pylint: disable=W0613
    494         """Method: blockchain.transaction.get
    495         Return a raw transaction.
    496         """
    497         if "params" not in query or len(query["params"]) < 1:
    498             return ERRORS["invalidparams"]
    499         tx_hash = query["params"][0]
    500         verbose = query["params"][1] if len(query["params"]) > 1 else False
    501 
    502         # _ec, rawtx = await self.bx.fetch_blockchain_transaction(tx_hash)
    503         _ec, rawtx = await self.bx.fetch_mempool_transaction(tx_hash)
    504         if _ec and _ec != 0:
    505             self.log.debug("Got error: %s", repr(_ec))
    506             return ERRORS["internalerror"]
    507 
    508         # Behaviour is undefined in spec
    509         if not rawtx:
    510             return {"result": None}
    511 
    512         if verbose:
    513             # TODO: Help needed
    514             return ERRORS["invalidrequest"]
    515 
    516         return {"result": bh2u(rawtx)}
    517 
    518     async def blockchain_transaction_get_merkle(self, writer, query):  # pylint: disable=W0613
    519         """Method: blockchain.transaction.get_merkle
    520         Return the merkle branch to a confirmed transaction given its
    521         hash and height.
    522         """
    523         if "params" not in query or len(query["params"]) != 2:
    524             return ERRORS["invalidparams"]
    525         tx_hash = query["params"][0]
    526         height = query["params"][1]
    527 
    528         if not is_hash256_str(tx_hash):
    529             return ERRORS["invalidparams"]
    530         if not is_non_negative_integer(height):
    531             return ERRORS["invalidparams"]
    532 
    533         _ec, hashes = await self.bx.fetch_block_transaction_hashes(height)
    534         if _ec and _ec != 0:
    535             self.log.debug("Got error: %s", repr(_ec))
    536             return ERRORS["internalerror"]
    537 
    538         # Decouple from tuples
    539         hashes = [i[0] for i in hashes]
    540         tx_pos = hashes.index(unhexlify(tx_hash)[::-1])
    541         branch = merkle_branch(hashes, tx_pos)
    542 
    543         res = {
    544             "block_height": int(height),
    545             "pos": int(tx_pos),
    546             "merkle": branch,
    547         }
    548         return {"result": res}
    549 
    550     async def blockchain_transaction_from_pos(self, writer, query):  # pylint: disable=R0911,W0613
    551         """Method: blockchain.transaction.id_from_pos
    552         Return a transaction hash and optionally a merkle proof, given a
    553         block height and a position in the block.
    554         """
    555         if "params" not in query or len(query["params"]) < 2:
    556             return ERRORS["invalidparams"]
    557         height = query["params"][0]
    558         tx_pos = query["params"][1]
    559         merkle = query["params"][2] if len(query["params"]) > 2 else False
    560 
    561         if not is_non_negative_integer(height):
    562             return ERRORS["invalidparams"]
    563         if not is_non_negative_integer(tx_pos):
    564             return ERRORS["invalidparams"]
    565         if not is_boolean(merkle):
    566             return ERRORS["invalidparams"]
    567 
    568         _ec, hashes = await self.bx.fetch_block_transaction_hashes(height)
    569         if _ec and _ec != 0:
    570             self.log.debug("Got error: %s", repr(_ec))
    571             return ERRORS["internalerror"]
    572 
    573         if len(hashes) - 1 < tx_pos:
    574             return ERRORS["internalerror"]
    575 
    576         # Decouple from tuples
    577         hashes = [i[0] for i in hashes]
    578         txid = hash_to_hex_str(hashes[tx_pos])
    579 
    580         if not merkle:
    581             return {"result": txid}
    582         branch = merkle_branch(hashes, tx_pos)
    583         return {"result": {"tx_hash": txid, "merkle": branch}}
    584 
    585     async def mempool_get_fee_histogram(self, writer, query):  # pylint: disable=W0613
    586         """Method: mempool.get_fee_histogram
    587         Return a histogram of the fee rates paid by transactions in the
    588         memory pool, weighted by transaction size.
    589         """
    590         # TODO: Help wanted
    591         return {"result": [[0, 0]]}
    592 
    593     async def server_add_peer(self, writer, query):  # pylint: disable=W0613
    594         """Method: server.add_peer
    595         A newly-started server uses this call to get itself into other
    596         servers’ peers lists. It should not be used by wallet clients.
    597         """
    598         # TODO: Help wanted
    599         return {"result": False}
    600 
    601     async def server_banner(self, writer, query):  # pylint: disable=W0613
    602         """Method: server.banner
    603         Return a banner to be shown in the Electrum console.
    604         """
    605         return {"result": BANNER}
    606 
    607     async def server_donation_address(self, writer, query):  # pylint: disable=W0613
    608         """Method: server.donation_address
    609         Return a server donation address.
    610         """
    611         return {"result": DONATION_ADDR}
    612 
    613     async def server_features(self, writer, query):  # pylint: disable=W0613
    614         """Method: server.features
    615         Return a list of features and services supported by the server.
    616         """
    617         cfg = self.server_cfg
    618         return {
    619             "result": {
    620                 "genesis_hash": self.genesis,
    621                 "hosts": {
    622                     cfg["server_hostname"]: {
    623                         "tcp_port": cfg["server_port"],
    624                         "ssl_port": None,
    625                     },
    626                 },
    627                 "protocol_max": SERVER_PROTO_MAX,
    628                 "protocol_min": SERVER_PROTO_MIN,
    629                 "pruning": None,
    630                 "server_version": f"obelisk {VERSION}",
    631                 "hash_function": "sha256",
    632             }
    633         }
    634 
    635     async def server_peers_subscribe(self, writer, query):  # pylint: disable=W0613
    636         """Method: server.peers.subscribe
    637         Return a list of peer servers. Despite the name this is not a
    638         subscription and the server must send no notifications.
    639         """
    640         # TODO: Help wanted
    641         return {"result": []}
    642 
    643     async def server_ping(self, writer, query):  # pylint: disable=W0613
    644         """Method: server.ping
    645         Ping the server to ensure it is responding, and to keep the session
    646         alive. The server may disconnect clients that have sent no requests
    647         for roughly 10 minutes.
    648         """
    649         return {"result": None}
    650 
    651     async def server_version(self, writer, query):  # pylint: disable=W0613
    652         """Method: server.version
    653         Identify the client to the server and negotiate the protocol version.
    654         """
    655         if "params" not in query or len(query["params"]) != 2:
    656             return ERRORS["invalidparams"]
    657         client_ver = query["params"][1]
    658         if isinstance(client_ver, list):
    659             client_min, client_max = client_ver[0], client_ver[1]
    660         else:
    661             client_min = client_max = client_ver
    662         version = min(client_max, SERVER_PROTO_MAX)
    663         if version < max(client_min, SERVER_PROTO_MIN):
    664             return ERRORS["protonotsupported"]
    665         return {"result": [f"obelisk {VERSION}", version]}