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]}