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 (31114B)
---
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 import struct
23 from binascii import unhexlify
24 from traceback import print_exc
25
26 from obelisk.errors_jsonrpc import JsonRPCError
27 from obelisk.errors_libbitcoin import ZMQError
28 from obelisk.mempool_api import get_mempool_fee_estimates
29 from obelisk.merkle import merkle_branch, merkle_branch_and_root
30 from obelisk.util import (
31 bh2u,
32 block_to_header,
33 is_boolean,
34 is_hash256_str,
35 is_hex_str,
36 is_non_negative_integer,
37 safe_hexlify,
38 sha256,
39 double_sha256,
40 hash_to_hex_str,
41 )
42 from obelisk.zeromq import Client
43
44 VERSION = "0.0"
45 SERVER_PROTO_MIN = "1.4"
46 SERVER_PROTO_MAX = "1.4.2"
47 DONATION_ADDR = "bc1q7an9p5pz6pjwjk4r48zke2yfaevafzpglg26mz"
48
49 BANNER = ("""
50 Welcome to obelisk
51
52 "Tools for the people"
53
54 obelisk is a server that uses libbitcoin-server as its backend.
55 Source code can be found at: https://github.com/parazyd/obelisk
56
57 Please consider donating: %s
58 """ % DONATION_ADDR)
59
60
61 class ElectrumProtocol(asyncio.Protocol): # pylint: disable=R0904,R0902
62 """Class implementing the Electrum protocol, with async support"""
63
64 def __init__(self, log, chain, endpoints, server_cfg):
65 self.log = log
66 self.stopped = False
67 self.endpoints = endpoints
68 self.server_cfg = server_cfg
69 self.loop = asyncio.get_event_loop()
70 self.bx = Client(log, endpoints, self.loop)
71 self.block_queue = None
72 self.peers = {}
73
74 self.chain = chain
75 if self.chain == "mainnet": # pragma: no cover
76 self.genesis = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"
77 elif self.chain == "testnet":
78 self.genesis = "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943"
79 else:
80 raise ValueError(f"Invalid chain '{chain}'") # pragma: no cover
81
82 # Here we map available methods to their respective functions
83 self.methodmap = {
84 "blockchain.block.header": self.block_header,
85 "blockchain.block.headers": self.block_headers,
86 "blockchain.estimatefee": self.estimatefee,
87 "blockchain.headers.subscribe": self.headers_subscribe,
88 "blockchain.relayfee": self.relayfee,
89 "blockchain.scripthash.get_balance": self.scripthash_get_balance,
90 "blockchain.scripthash.get_history": self.scripthash_get_history,
91 "blockchain.scripthash.get_mempool": self.scripthash_get_mempool,
92 "blockchain.scripthash.listunspent": self.scripthash_listunspent,
93 "blockchain.scripthash.subscribe": self.scripthash_subscribe,
94 "blockchain.scripthash.unsubscribe": self.scripthash_unsubscribe,
95 "blockchain.transaction.broadcast": self.transaction_broadcast,
96 "blockchain.transaction.get": self.transaction_get,
97 "blockchain.transaction.get_merkle": self.transaction_get_merkle,
98 "blockchain.transaction.id_from_pos": self.transaction_id_from_pos,
99 "mempool.get_fee_histogram": self.get_fee_histogram,
100 "server_add_peer": self.add_peer,
101 "server.banner": self.banner,
102 "server.donation_address": self.donation_address,
103 "server.features": self.server_features,
104 "server.peers.subscribe": self.peers_subscribe,
105 "server.ping": self.ping,
106 "server.version": self.server_version,
107 }
108
109 async def stop(self):
110 """Destructor function"""
111 self.log.debug("ElectrumProtocol.stop()")
112 self.stopped = True
113 if self.bx:
114 for i in self.peers:
115 await self._peer_cleanup(i)
116 await self.bx.stop()
117
118 async def _peer_cleanup(self, peer):
119 """Cleanup tasks and data for peer"""
120 self.log.debug("Cleaning up data for %s", peer)
121 for i in self.peers[peer]["tasks"]:
122 i.cancel()
123 for i in self.peers[peer]["sh"]:
124 self.peers[peer]["sh"][i]["task"].cancel()
125
126 @staticmethod
127 def _get_peer(writer):
128 peer_t = writer._transport.get_extra_info("peername") # pylint: disable=W0212
129 return f"{peer_t[0]}:{peer_t[1]}"
130
131 async def recv(self, reader, writer):
132 """Loop ran upon a connection which acts as a JSON-RPC handler"""
133 recv_buf = bytearray()
134 self.peers[self._get_peer(writer)] = {"tasks": [], "sh": {}}
135
136 while not self.stopped:
137 data = await reader.read(4096)
138 if not data or len(data) == 0:
139 await self._peer_cleanup(self._get_peer(writer))
140 return
141 recv_buf.extend(data)
142 lb = recv_buf.find(b"\n")
143 if lb == -1:
144 continue
145 while lb != -1:
146 line = recv_buf[:lb].rstrip()
147 recv_buf = recv_buf[lb + 1:]
148 lb = recv_buf.find(b"\n")
149 try:
150 line = line.decode("utf-8")
151 query = json.loads(line)
152 except (UnicodeDecodeError, json.JSONDecodeError) as err:
153 self.log.debug("%s", print_exc)
154 self.log.debug("Decode error: %s", repr(err))
155 break
156 self.log.debug("=> %s", line)
157 await self.handle_query(writer, query)
158
159 async def _send_notification(self, writer, method, params):
160 """Send JSON-RPC notification to given writer"""
161 response = {"jsonrpc": "2.0", "method": method, "params": params}
162 self.log.debug("<= %s", response)
163 writer.write(json.dumps(response).encode("utf-8") + b"\n")
164 await writer.drain()
165
166 async def _send_response(self, writer, result, nid):
167 """Send successful JSON-RPC response to given writer"""
168 response = {"jsonrpc": "2.0", "result": result, "id": nid}
169 self.log.debug("<= %s", response)
170 writer.write(json.dumps(response).encode("utf-8") + b"\n")
171 await writer.drain()
172
173 async def _send_error(self, writer, error):
174 """Send JSON-RPC error to given writer"""
175 response = {"jsonrpc": "2.0", "error": error, "id": None}
176 self.log.debug("<= %s", response)
177 writer.write(json.dumps(response).encode("utf-8") + b"\n")
178 await writer.drain()
179
180 async def _send_reply(self, writer, resp, query):
181 """Wrap function for sending replies"""
182 if "error" in resp:
183 return await self._send_error(writer, resp["error"])
184 return await self._send_response(writer, resp["result"], query["id"])
185
186 async def handle_query(self, writer, query): # pylint: disable=R0915,R0912,R0911
187 """Electrum protocol method handler mapper"""
188 if "method" not in query or "id" not in query:
189 return await self._send_reply(writer, JsonRPCError.invalidrequest(),
190 None)
191
192 method = query["method"]
193 func = self.methodmap.get(method)
194 if not func:
195 self.log.error("Unhandled method %s, query=%s", method, query)
196 return await self._send_reply(writer, JsonRPCError.methodnotfound(),
197 query)
198 resp = await func(writer, query)
199 return await self._send_reply(writer, resp, query)
200
201 async def _merkle_proof_for_headers(self, height, idx):
202 """Extremely inefficient merkle proof for headers"""
203 # The following works, but is extremely inefficient.
204 # The best solution would be to figure something out in
205 # libbitcoin-server
206 cp_headers = []
207
208 for i in range(0, height + 1):
209 _ec, data = await self.bx.fetch_block_header(i)
210 if _ec and _ec != ZMQError.success:
211 self.log.error("bx.fetch_block_header: %s", _ec.name)
212 return JsonRPCError.internalerror()
213 cp_headers.append(data)
214
215 branch, root = merkle_branch_and_root(
216 [double_sha256(i) for i in cp_headers], idx)
217
218 return {
219 "branch": [hash_to_hex_str(i) for i in branch],
220 "header": safe_hexlify(cp_headers[idx]),
221 "root": hash_to_hex_str(root),
222 }
223
224 async def block_header(self, writer, query): # pylint: disable=W0613,R0911
225 """Method: blockchain.block.header
226 Return the block header at the given height.
227 """
228 if "params" not in query or len(query["params"]) < 1:
229 return JsonRPCError.invalidparams()
230 index = query["params"][0]
231 cp_height = query["params"][1] if len(query["params"]) == 2 else 0
232
233 if not is_non_negative_integer(index):
234 return JsonRPCError.invalidparams()
235 if not is_non_negative_integer(cp_height):
236 return JsonRPCError.invalidparams()
237 if cp_height != 0 and not index <= cp_height:
238 return JsonRPCError.invalidparams()
239
240 if cp_height == 0:
241 _ec, header = await self.bx.fetch_block_header(index)
242 if _ec and _ec != ZMQError.success:
243 self.log.error("bx.fetch_block_header: %s", _ec.name)
244 return JsonRPCError.internalerror()
245 return {"result": safe_hexlify(header)}
246
247 res = await self._merkle_proof_for_headers(cp_height, index)
248 return {"result": res}
249
250 async def block_headers(self, writer, query): # pylint: disable=W0613,R0911
251 """Method: blockchain.block.headers
252 Return a concatenated chunk of block headers from the main chain.
253 """
254 if "params" not in query or len(query["params"]) < 2:
255 return JsonRPCError.invalidparams()
256 # Electrum doesn't allow max_chunk_size to be less than 2016
257 # gopher://bitreich.org/9/memecache/convenience-store.mkv
258 max_chunk_size = 2016
259 start_height = query["params"][0]
260 count = query["params"][1]
261 cp_height = query["params"][2] if len(query["params"]) == 3 else 0
262
263 if not is_non_negative_integer(start_height):
264 return JsonRPCError.invalidparams()
265 if not is_non_negative_integer(count):
266 return JsonRPCError.invalidparams()
267 # BUG: spec says <= cp_height
268 if cp_height != 0 and not start_height + (count - 1) < cp_height:
269 return JsonRPCError.invalidparams()
270
271 count = min(count, max_chunk_size)
272 headers = bytearray()
273 for i in range(count):
274 _ec, data = await self.bx.fetch_block_header(start_height + i)
275 if _ec and _ec != ZMQError.success:
276 self.log.error("bx.fetch_block_header: %s", _ec.name)
277 return JsonRPCError.internalerror()
278 headers.extend(data)
279
280 resp = {
281 "hex": safe_hexlify(headers),
282 "count": len(headers) // 80,
283 "max": max_chunk_size,
284 }
285
286 if cp_height > 0:
287 data = await self._merkle_proof_for_headers(
288 cp_height, start_height + (len(headers) // 80) - 1)
289 resp["branch"] = data["branch"]
290 resp["root"] = data["root"]
291
292 return {"result": resp}
293
294 async def estimatefee(self, writer, query): # pylint: disable=W0613,disable=R0911
295 """Method: blockchain.estimatefee
296 Return the estimated transaction fee per kilobyte for a transaction
297 to be confirmed within a certain number of blocks.
298 """
299 # NOTE: This solution is using the mempool.space API.
300 # Let's try to eventually solve it with some internal logic.
301 if "params" not in query or len(query["params"]) != 1:
302 return JsonRPCError.invalidparams()
303
304 num_blocks = query["params"][0]
305 if not is_non_negative_integer(num_blocks):
306 return JsonRPCError.invalidparams()
307
308 if self.chain == "testnet":
309 return {"result": 0.00001}
310
311 fee_dict = get_mempool_fee_estimates()
312 if not fee_dict:
313 return {"result": -1}
314
315 # Good enough.
316 if num_blocks < 3:
317 return {"result": "{:.8f}".format(fee_dict["fastestFee"] / 100000)}
318
319 if num_blocks < 6:
320 return {"result": "{:.8f}".format(fee_dict["halfHourFee"] / 100000)}
321
322 if num_blocks < 10:
323 return {"result": "{:.8f}".format(fee_dict["hourFee"] / 100000)}
324
325 return {"result": "{:.8f}".format(fee_dict["minimumFee"] / 100000)}
326
327 async def header_notifier(self, writer):
328 self.block_queue = asyncio.Queue()
329 await self.bx.subscribe_to_blocks(self.block_queue)
330 while True:
331 item = await self.block_queue.get()
332 if len(item) != 3:
333 self.log.debug("error: item from block queue len != 3")
334 continue
335
336 header = block_to_header(item[2])
337 params = [{"height": item[1], "hex": safe_hexlify(header)}]
338 await self._send_notification(writer,
339 "blockchain.headers.subscribe",
340 params)
341
342 async def headers_subscribe(self, writer, query): # pylint: disable=W0613
343 """Method: blockchain.headers.subscribe
344 Subscribe to receive block headers when a new block is found.
345 """
346 # Tip height and header are returned upon request
347 _ec, height = await self.bx.fetch_last_height()
348 if _ec and _ec != ZMQError.success:
349 self.log.error("bx.fetch_last_height: %s", _ec.name)
350 return JsonRPCError.internalerror()
351 _ec, tip_header = await self.bx.fetch_block_header(height)
352 if _ec and _ec != ZMQError.success:
353 self.log.error("bx.fetch_block_header: %s", _ec.name)
354 return JsonRPCError.internalerror()
355
356 self.peers[self._get_peer(writer)]["tasks"].append(
357 asyncio.create_task(self.header_notifier(writer)))
358 ret = {"height": height, "hex": safe_hexlify(tip_header)}
359 return {"result": ret}
360
361 async def relayfee(self, writer, query): # pylint: disable=W0613
362 """Method: blockchain.relayfee
363 Return the minimum fee a low-priority transaction must pay in order
364 to be accepted to the daemon’s memory pool.
365 """
366 return {"result": 0.00001}
367
368 async def scripthash_get_balance(self, writer, query): # pylint: disable=W0613
369 """Method: blockchain.scripthash.get_balance
370 Return the confirmed and unconfirmed balances of a script hash.
371 """
372 if "params" not in query or len(query["params"]) != 1:
373 return JsonRPCError.invalidparams()
374
375 if not is_hash256_str(query["params"][0]):
376 return JsonRPCError.invalidparams()
377
378 _ec, data = await self.bx.fetch_balance(query["params"][0])
379 if _ec and _ec != ZMQError.success:
380 self.log.error("bx.fetch_balance: %s", _ec.name)
381 return JsonRPCError.internalerror()
382
383 ret = {"confirmed": data[0], "unconfirmed": data[1]}
384 return {"result": ret}
385
386 async def scripthash_get_history(self, writer, query): # pylint: disable=W0613
387 """Method: blockchain.scripthash.get_history
388 Return the confirmed and unconfirmed history of a script hash.
389 """
390 if "params" not in query or len(query["params"]) != 1:
391 return JsonRPCError.invalidparams()
392
393 if not is_hash256_str(query["params"][0]):
394 return JsonRPCError.invalidparams()
395
396 _ec, data = await self.bx.fetch_history4(query["params"][0])
397 if _ec and _ec != ZMQError.success:
398 self.log.error("bx.fetch_history4: %s", _ec.name)
399 return JsonRPCError.internalerror()
400
401 self.log.debug("hist: %s", data)
402 ret = []
403 # TODO: mempool
404 for i in data:
405 if "received" in i:
406 ret.append({
407 "height": i["received"]["height"],
408 "tx_hash": hash_to_hex_str(i["received"]["hash"]),
409 })
410 if "spent" in i:
411 ret.append({
412 "height": i["spent"]["height"],
413 "tx_hash": hash_to_hex_str(i["spent"]["hash"]),
414 })
415
416 return {"result": ret}
417
418 async def scripthash_get_mempool(self, writer, query): # pylint: disable=W0613
419 """Method: blockchain.scripthash.get_mempool
420 Return the unconfirmed transactions of a script hash.
421 """
422 # TODO: Implement
423 return JsonRPCError.invalidrequest()
424
425 async def scripthash_listunspent(self, writer, query): # pylint: disable=W0613
426 """Method: blockchain.scripthash.listunspent
427 Return an ordered list of UTXOs sent to a script hash.
428 """
429 if "params" not in query or len(query["params"]) != 1:
430 return JsonRPCError.invalidparams()
431
432 scripthash = query["params"][0]
433 if not is_hash256_str(scripthash):
434 return JsonRPCError.invalidparams()
435
436 _ec, utxo = await self.bx.fetch_utxo(scripthash)
437 if _ec and _ec != ZMQError.success:
438 self.log.error("bx.fetch_utxo: %s", _ec.name)
439 return JsonRPCError.internalerror()
440
441 ret = []
442 for i in utxo:
443 rec = i["received"]
444 ret.append({
445 "tx_pos": rec["index"],
446 "value": i["value"],
447 "tx_hash": hash_to_hex_str(rec["hash"]),
448 "height": rec["height"] if rec["height"] != 4294967295 else 0,
449 })
450
451 return {"result": ret}
452
453 async def scripthash_renewer(self, scripthash, queue):
454 while True:
455 try:
456 self.log.debug("scriphash renewer: %s", scripthash)
457 _ec = await self.bx.subscribe_scripthash(scripthash, queue)
458 if _ec and _ec != ZMQError.success:
459 self.log.error("bx.subscribe_scripthash: %s", _ec.name)
460 await asyncio.sleep(60)
461 except asyncio.CancelledError:
462 self.log.debug("subscription cancelled: %s", scripthash)
463 break
464
465 async def scripthash_notifier(self, writer, scripthash):
466 # TODO: Mempool
467 # TODO: This is still flaky and not always notified. Investigate.
468 self.log.debug("notifier")
469 method = "blockchain.scripthash.subscribe"
470 queue = asyncio.Queue()
471 renew_task = asyncio.create_task(
472 self.scripthash_renewer(scripthash, queue))
473
474 while True:
475 try:
476 item = await queue.get()
477 _ec, height, txid = struct.unpack("<HI32s", item)
478
479 self.log.debug("shnotifier: _ec: %d", _ec)
480 self.log.debug("shnotifier: height: %d", height)
481 self.log.debug("shnotifier: txid: %s", hash_to_hex_str(txid))
482
483 if (_ec == ZMQError.service_stopped.value and height == 0 and
484 not self.stopped):
485 self.log.debug("subscription expired: %s", scripthash)
486 # Subscription expired
487 continue
488
489 self.peers[self._get_peer(writer)]["sh"]["status"].append(
490 (hash_to_hex_str(txid), height))
491
492 params = [
493 scripthash,
494 ElectrumProtocol.__scripthash_status_encode(self.peers[
495 self._get_peer(writer)]["sh"]["scripthash"]["status"]),
496 ]
497 await self._send_notification(writer, method, params)
498 except asyncio.CancelledError:
499 break
500 renew_task.cancel()
501
502 async def scripthash_subscribe(self, writer, query): # pylint: disable=W0613
503 """Method: blockchain.scripthash.subscribe
504 Subscribe to a script hash.
505 """
506 if "params" not in query or len(query["params"]) != 1:
507 return JsonRPCError.invalidparams()
508
509 scripthash = query["params"][0]
510 if not is_hash256_str(scripthash):
511 return JsonRPCError.invalidparams()
512
513 _ec, history = await self.bx.fetch_history4(scripthash)
514 if _ec and _ec != ZMQError.success:
515 self.log.error("bx.fetch_history4: %s", _ec.name)
516 return JsonRPCError.internalerror()
517
518 # TODO: Check how history4 acts for mempool/unconfirmed
519 status = ElectrumProtocol.__scripthash_status_from_history(history)
520 self.peers[self._get_peer(writer)]["sh"][scripthash] = {
521 "status": status
522 }
523
524 task = asyncio.create_task(self.scripthash_notifier(writer, scripthash))
525 self.peers[self._get_peer(writer)]["sh"][scripthash]["task"] = task
526
527 if len(history) < 1:
528 return {"result": None}
529 return {"result": ElectrumProtocol.__scripthash_status_encode(status)}
530
531 @staticmethod
532 def __scripthash_status_from_history(history):
533 status = []
534 for i in history:
535 if "received" in i:
536 status.append((
537 hash_to_hex_str(i["received"]["hash"]),
538 i["received"]["height"],
539 ))
540 if "spent" in i:
541 status.append((
542 hash_to_hex_str(i["spent"]["hash"]),
543 i["spent"]["height"],
544 ))
545 return status
546
547 @staticmethod
548 def __scripthash_status_encode(status):
549 concat = ""
550 for txid, height in status:
551 concat += txid + ":%d:" % height
552 return bh2u(sha256(concat.encode("ascii")))
553
554 async def scripthash_unsubscribe(self, writer, query): # pylint: disable=W0613
555 """Method: blockchain.scripthash.unsubscribe
556 Unsubscribe from a script hash, preventing future notifications
557 if its status changes.
558 """
559 if "params" not in query or len(query["params"]) != 1:
560 return JsonRPCError.invalidparams()
561
562 scripthash = query["params"][0]
563 if not is_hash256_str(scripthash):
564 return JsonRPCError.invalidparams()
565
566 if scripthash in self.peers[self._get_peer(writer)]["sh"]:
567 self.peers[self._get_peer(
568 writer)]["sh"][scripthash]["task"].cancel()
569 # await self.bx.unsubscribe_scripthash(scripthash)
570 del self.peers[self._get_peer(writer)]["sh"][scripthash]
571 return {"result": True}
572
573 return {"result": False}
574
575 async def transaction_broadcast(self, writer, query): # pylint: disable=W0613
576 """Method: blockchain.transaction.broadcast
577 Broadcast a transaction to the network.
578 """
579 # Note: Not yet implemented in bs v4
580 if "params" not in query or len(query["params"]) != 1:
581 return JsonRPCError.invalidparams()
582
583 hextx = query["params"][0]
584 if not is_hex_str(hextx):
585 return JsonRPCError.invalidparams()
586
587 _ec, _ = await self.bx.broadcast_transaction(unhexlify(hextx)[::-1])
588 if _ec and _ec != ZMQError.success:
589 self.log.error("bx.broadcast_transaction: %s", _ec.name)
590 return JsonRPCError.internalerror()
591
592 rawtx = unhexlify(hextx)
593 txid = double_sha256(rawtx)
594 return {"result": hash_to_hex_str(txid)}
595
596 async def transaction_get(self, writer, query): # pylint: disable=W0613
597 """Method: blockchain.transaction.get
598 Return a raw transaction.
599 """
600 if "params" not in query or len(query["params"]) < 1:
601 return JsonRPCError.invalidparams()
602
603 tx_hash = query["params"][0]
604 verbose = query["params"][1] if len(query["params"]) > 1 else False
605
606 if not is_hex_str(tx_hash):
607 return JsonRPCError.invalidparams()
608
609 # _ec, rawtx = await self.bx.fetch_blockchain_transaction(tx_hash)
610 _ec, rawtx = await self.bx.fetch_mempool_transaction(tx_hash)
611 if _ec and _ec != ZMQError.success and _ec != ZMQError.not_found:
612 self.log.error("fetch_mempool_transaction: %s", _ec.name)
613 return JsonRPCError.internalerror()
614
615 # Behaviour is undefined in spec
616 if not rawtx:
617 return JsonRPCError.internalerror()
618 # return {"result": None}
619
620 if verbose:
621 # TODO: Help needed
622 return JsonRPCError.invalidrequest()
623
624 return {"result": bh2u(rawtx)}
625
626 async def transaction_get_merkle(self, writer, query): # pylint: disable=W0613
627 """Method: blockchain.transaction.get_merkle
628 Return the merkle branch to a confirmed transaction given its
629 hash and height.
630 """
631 if "params" not in query or len(query["params"]) != 2:
632 return JsonRPCError.invalidparams()
633
634 tx_hash = query["params"][0]
635 height = query["params"][1]
636
637 if not is_hash256_str(tx_hash):
638 return JsonRPCError.invalidparams()
639 if not is_non_negative_integer(height):
640 return JsonRPCError.invalidparams()
641
642 _ec, hashes = await self.bx.fetch_block_transaction_hashes(height)
643 if _ec and _ec != ZMQError.success:
644 self.log.error("bx.fetch_block_transaction_hashes: %s", _ec.name)
645 return JsonRPCError.internalerror()
646
647 # Decouple from tuples
648 hashes = [i[0] for i in hashes]
649 tx_pos = hashes.index(unhexlify(tx_hash)[::-1])
650 branch = merkle_branch(hashes, tx_pos)
651
652 res = {
653 "block_height": int(height),
654 "pos": int(tx_pos),
655 "merkle": branch,
656 }
657 return {"result": res}
658
659 async def transaction_id_from_pos(self, writer, query): # pylint: disable=R0911,W0613
660 """Method: blockchain.transaction.id_from_pos
661 Return a transaction hash and optionally a merkle proof, given a
662 block height and a position in the block.
663 """
664 if "params" not in query or len(query["params"]) < 2:
665 return JsonRPCError.invalidparams()
666
667 height = query["params"][0]
668 tx_pos = query["params"][1]
669 merkle = query["params"][2] if len(query["params"]) > 2 else False
670
671 if not is_non_negative_integer(height):
672 return JsonRPCError.invalidparams()
673 if not is_non_negative_integer(tx_pos):
674 return JsonRPCError.invalidparams()
675 if not is_boolean(merkle):
676 return JsonRPCError.invalidparams()
677
678 _ec, hashes = await self.bx.fetch_block_transaction_hashes(height)
679 if _ec and _ec != ZMQError.success:
680 self.log.error("bx.fetch_block_transaction_hashes: %s", _ec.name)
681 return JsonRPCError.internalerror()
682
683 if len(hashes) - 1 < tx_pos:
684 return JsonRPCError.internalerror()
685
686 # Decouple from tuples
687 hashes = [i[0] for i in hashes]
688 txid = hash_to_hex_str(hashes[tx_pos])
689
690 if not merkle:
691 return {"result": txid}
692
693 branch = merkle_branch(hashes, tx_pos)
694 return {"result": {"tx_hash": txid, "merkle": branch}}
695
696 async def get_fee_histogram(self, writer, query): # pylint: disable=W0613
697 """Method: mempool.get_fee_histogram
698 Return a histogram of the fee rates paid by transactions in the
699 memory pool, weighted by transaction size.
700 """
701 # TODO: Help wanted
702 return {"result": [[0, 0]]}
703
704 async def add_peer(self, writer, query): # pylint: disable=W0613
705 """Method: server.add_peer
706 A newly-started server uses this call to get itself into other
707 servers’ peers lists. It should not be used by wallet clients.
708 """
709 # TODO: Help wanted
710 return {"result": False}
711
712 async def banner(self, writer, query): # pylint: disable=W0613
713 """Method: server.banner
714 Return a banner to be shown in the Electrum console.
715 """
716 _, bsversion = await self.bx.server_version()
717 banner = "%s\nobelisk version: %s\nlibbitcoin-server version: %s" % (
718 BANNER,
719 VERSION,
720 bsversion.decode(),
721 )
722 return {"result": banner}
723
724 async def donation_address(self, writer, query): # pylint: disable=W0613
725 """Method: server.donation_address
726 Return a server donation address.
727 """
728 return {"result": DONATION_ADDR}
729
730 async def server_features(self, writer, query): # pylint: disable=W0613 # pragma: no cover
731 """Method: server.features
732 Return a list of features and services supported by the server.
733 """
734 cfg = self.server_cfg
735 hosts = {}
736 for host in cfg["server_hostnames"]:
737 hosts[host] = {"tcp_port": cfg["server_port"]}
738
739 return {
740 "result": {
741 "genesis_hash": self.genesis,
742 "hosts": hosts,
743 "protocol_max": SERVER_PROTO_MAX,
744 "protocol_min": SERVER_PROTO_MIN,
745 "pruning": None,
746 "server_version": f"obelisk {VERSION}",
747 "hash_function": "sha256",
748 }
749 }
750
751 async def peers_subscribe(self, writer, query): # pylint: disable=W0613
752 """Method: server.peers.subscribe
753 Return a list of peer servers. Despite the name this is not a
754 subscription and the server must send no notifications.
755 """
756 # TODO: Help wanted
757 return {"result": []}
758
759 async def ping(self, writer, query): # pylint: disable=W0613
760 """Method: server.ping
761 Ping the server to ensure it is responding, and to keep the session
762 alive. The server may disconnect clients that have sent no requests
763 for roughly 10 minutes.
764 """
765 return {"result": None}
766
767 async def server_version(self, writer, query): # pylint: disable=W0613
768 """Method: server.version
769 Identify the client to the server and negotiate the protocol version.
770 """
771 if "params" not in query or len(query["params"]) != 2:
772 return JsonRPCError.invalidparams()
773
774 client_ver = query["params"][1]
775
776 if isinstance(client_ver, list):
777 client_min, client_max = client_ver[0], client_ver[1]
778 else:
779 client_min = client_max = client_ver
780
781 version = min(client_max, SERVER_PROTO_MAX)
782
783 if version < max(client_min, SERVER_PROTO_MIN):
784 return JsonRPCError.protonotsupported()
785
786 return {"result": [f"obelisk {VERSION}", version]}