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