obelisk

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

commit bd0f5497d09c336ca58688672f4b941c5ac9c688
parent 9a979e07288739f67b597afc91da21784baa10d9
Author: parazyd <parazyd@dyne.org>
Date:   Thu,  8 Apr 2021 12:32:59 +0200

Implement blockchain.scripthash.get_balance

Diffstat:
Melectrumobelisk/protocol.py | 15++++++++++++++-
Melectrumobelisk/zeromq.py | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 132 insertions(+), 1 deletion(-)

diff --git a/electrumobelisk/protocol.py b/electrumobelisk/protocol.py @@ -253,7 +253,20 @@ class ElectrumProtocol(asyncio.Protocol): # pylint: disable=R0904,R0902 """Method: blockchain.scripthash.get_balance Return the confirmed and unconfirmed balances of a script hash. """ - return + if "params" not in query or len(query["params"]) != 1: + return {"error": "malformed query"} + + if not is_hash256_str(query["params"][0]): + return {"error": "invalid scripthash"} + + _ec, data = await self.bx.fetch_balance(query["params"][0]) + if _ec and _ec != 0: + self.log.debug("Got erorr: %s", repr(_ec)) + return {"error": "request corrupted"} + + # TODO: confirmed/unconfirmed, see what's happening in libbitcoin + ret = {"confirmed": data, "unconfirmed": 0} + return {"result": ret} async def blockchain_scripthash_get_history(self, query): """Method: blockchain.scripthash.get_history diff --git a/electrumobelisk/zeromq.py b/electrumobelisk/zeromq.py @@ -16,12 +16,14 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. """ZeroMQ implementation for libbitcoin""" import asyncio +import functools import struct from binascii import unhexlify from random import randint import zmq import zmq.asyncio +from bitcoin.core import COutPoint from electrumobelisk.libbitcoin_errors import make_error_code, ErrorCode from electrumobelisk.util import bh2u @@ -47,6 +49,32 @@ def pack_block_index(index): ) +def to_int(xbytes): + """Make little-endian integer from given bytes""" + return int.from_bytes(xbytes, byteorder="little") + + +def checksum(xhash, index): + """ + This method takes a transaction hash and an index and returns a checksum. + + This checksum is based on 49 bits starting from the 12th byte of the + reversed hash. Combined with the last 15 bits of the 4 byte index. + """ + mask = 0xFFFFFFFFFFFF8000 + magic_start_position = 12 + + hash_bytes = bytes.fromhex(xhash)[::-1] + last_20_bytes = hash_bytes[magic_start_position:] + + assert len(hash_bytes) == 32 + assert index < 2**32 + + hash_upper_49_bits = to_int(last_20_bytes) & mask + index_lower_15_bits = index & ~mask + return hash_upper_49_bits | index_lower_15_bits + + def unpack_table(row_fmt, data): """Function to unpack table received from libbitcoin""" # Get the number of rows @@ -301,7 +329,97 @@ class Client: return error_code, None return error_code, bh2u(data) + async def fetch_history4(self, scripthash, height=0): + """Fetch history for given scripthash""" + # BUG: There is something strange happening sometimes, for example + # on testnet, where the following scripthash returns as if the + # coins are spent, but in fact they are not: + # 8cc0f8d0cc3808540466194713e9fe618f57a2585a18d93d6d9542c0b71edd92 + # 92dd1eb7c042956d3dd9185a58a2578f61fee91347196604540838ccd0f8c08c + command = b"blockchain.fetch_history4" + decoded_address = unhexlify(scripthash) + error_code, raw_points = await self._simple_request( + command, decoded_address + struct.pack("<I", height)) + if error_code: + return error_code, None + + def make_tuple(row): + kind, tx_hash, index, height, value = row + return ( + kind, + COutPoint(tx_hash, index), + height, + value, + checksum(tx_hash[::-1].hex(), index), + ) + + rows = unpack_table("<B32sIIQ", raw_points) + points = [make_tuple(row) for row in rows] + correlated_points = Client.__correlate(points) + return error_code, correlated_points + async def broadcast_transaction(self, rawtx): """Broadcast given raw transaction""" command = b"transaction_pool.broadcast" return await self._simple_request(command, rawtx) + + async def fetch_balance(self, scripthash): + """Fetch balance for given scripthash""" + error_code, history = await self.fetch_history4(scripthash) + if error_code: + return error_code, None + + utxo = Client.__receives_without_spends(history) + return error_code, functools.reduce( + lambda accumulator, point: accumulator + point["value"], utxo, 0) + + @staticmethod + def __receives_without_spends(history): + return (point for point in history if "spent" not in point) + + @staticmethod + def __correlate(points): + transfers, checksum_to_index = Client.__find_receives(points) + transfers = Client.__correlate_spends_to_receives( + points, transfers, checksum_to_index) + return transfers + + @staticmethod + def __correlate_spends_to_receives(points, transfers, checksum_to_index): + for point in points: + if point[0] == 0: # receive + continue + + spent = { + "hash": point[1].hash, + "height": point[2], + "index": point[1].n, + } + if point[3] not in checksum_to_index: + transfers.append({"spent": spent}) + else: + transfers[checksum_to_index[point[3]]]["spent"] = spent + + return transfers + + @staticmethod + def __find_receives(points): + transfers = [] + checksum_to_index = {} + + for point in points: + if point[0] == 1: # spent + continue + + transfers.append({ + "received": { + "hash": point[1].hash, + "height": point[2], + "index": point[1].n, + }, + "value": point[3], + }) + + checksum_to_index[point[4]] = len(transfers) - 1 + + return transfers, checksum_to_index