URI: 
       tprotocol.py - obelisk - Electrum server using libbitcoin as its backend
  HTML git clone https://git.parazyd.org/obelisk
   DIR Log
   DIR Files
   DIR Refs
   DIR README
   DIR LICENSE
       ---
       tprotocol.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]}