Neil Booth
8 years ago
26 changed files with 1864 additions and 942 deletions
@ -0,0 +1,11 @@ |
|||||
|
Version 0.02 |
||||
|
------------ |
||||
|
|
||||
|
- fix bug where tx counts were incorrectly saved |
||||
|
- large clean-up and refactoring of code, breakout into new files |
||||
|
- several efficiency improvements |
||||
|
- initial implementation of chain reorg handling |
||||
|
- work on RPC and TCP server functionality. Code committed but not |
||||
|
functional, so currently disabled |
||||
|
- note that some of the enivronment variables have been renamed, |
||||
|
see samples/scripts/NOTES for the list |
@ -0,0 +1,62 @@ |
|||||
|
#!/usr/bin/env python3 |
||||
|
|
||||
|
# See the file "LICENSE" for information about the copyright |
||||
|
# and warranty status of this software. |
||||
|
|
||||
|
import argparse |
||||
|
import asyncio |
||||
|
import json |
||||
|
from functools import partial |
||||
|
from os import environ |
||||
|
|
||||
|
|
||||
|
class RPCClient(asyncio.Protocol): |
||||
|
|
||||
|
def __init__(self, loop): |
||||
|
self.loop = loop |
||||
|
|
||||
|
def connection_made(self, transport): |
||||
|
self.transport = transport |
||||
|
|
||||
|
def connection_lost(self, exc): |
||||
|
self.loop.stop() |
||||
|
|
||||
|
def send(self, payload): |
||||
|
data = json.dumps(payload) + '\n' |
||||
|
self.transport.write(data.encode()) |
||||
|
|
||||
|
def data_received(self, data): |
||||
|
payload = json.loads(data.decode()) |
||||
|
self.transport.close() |
||||
|
print(json.dumps(payload, indent=4, sort_keys=True)) |
||||
|
|
||||
|
|
||||
|
def main(): |
||||
|
'''Send the RPC command to the server and print the result.''' |
||||
|
parser = argparse.ArgumentParser('Send electrumx an RPC command' ) |
||||
|
parser.add_argument('-p', '--port', metavar='port_num', type=int, |
||||
|
help='RPC port number') |
||||
|
parser.add_argument('command', nargs='*', default=[], |
||||
|
help='command to send') |
||||
|
args = parser.parse_args() |
||||
|
|
||||
|
if args.port is None: |
||||
|
args.port = int(environ.get('ELECTRUMX_RPC_PORT', 8000)) |
||||
|
|
||||
|
payload = {'method': args.command[0], 'params': args.command[1:]} |
||||
|
|
||||
|
loop = asyncio.get_event_loop() |
||||
|
proto_factory = partial(RPCClient, loop) |
||||
|
coro = loop.create_connection(proto_factory, 'localhost', args.port) |
||||
|
try: |
||||
|
transport, protocol = loop.run_until_complete(coro) |
||||
|
protocol.send(payload) |
||||
|
loop.run_forever() |
||||
|
except OSError: |
||||
|
print('error connecting - is ElectrumX running?') |
||||
|
finally: |
||||
|
loop.close() |
||||
|
|
||||
|
|
||||
|
if __name__ == '__main__': |
||||
|
main() |
@ -0,0 +1,11 @@ |
|||||
|
[Unit] |
||||
|
Description=Electrumx |
||||
|
After=network.target |
||||
|
|
||||
|
[Service] |
||||
|
EnvironmentFile=/etc/electrumx.conf |
||||
|
ExecStart=/home/electrumx/electrumx/server_main.py |
||||
|
User=electrumx |
||||
|
|
||||
|
[Install] |
||||
|
WantedBy=multi-user.target |
@ -0,0 +1,714 @@ |
|||||
|
# See the file "LICENSE" for information about the copyright |
||||
|
# and warranty status of this software. |
||||
|
|
||||
|
import array |
||||
|
import ast |
||||
|
import asyncio |
||||
|
import struct |
||||
|
import time |
||||
|
from bisect import bisect_left |
||||
|
from collections import defaultdict, namedtuple |
||||
|
from functools import partial |
||||
|
|
||||
|
import plyvel |
||||
|
|
||||
|
from server.cache import FSCache, UTXOCache, NO_CACHE_ENTRY |
||||
|
from server.daemon import DaemonError |
||||
|
from lib.hash import hash_to_str |
||||
|
from lib.script import ScriptPubKey |
||||
|
from lib.util import chunks, LoggedClass |
||||
|
|
||||
|
|
||||
|
def formatted_time(t): |
||||
|
'''Return a number of seconds as a string in days, hours, mins and |
||||
|
secs.''' |
||||
|
t = int(t) |
||||
|
return '{:d}d {:02d}h {:02d}m {:02d}s'.format( |
||||
|
t // 86400, (t % 86400) // 3600, (t % 3600) // 60, t % 60) |
||||
|
|
||||
|
|
||||
|
UTXO = namedtuple("UTXO", "tx_num tx_pos tx_hash height value") |
||||
|
|
||||
|
|
||||
|
class ChainError(Exception): |
||||
|
pass |
||||
|
|
||||
|
|
||||
|
class Prefetcher(LoggedClass): |
||||
|
'''Prefetches blocks (in the forward direction only).''' |
||||
|
|
||||
|
def __init__(self, daemon, height): |
||||
|
super().__init__() |
||||
|
self.daemon = daemon |
||||
|
self.semaphore = asyncio.Semaphore() |
||||
|
self.queue = asyncio.Queue() |
||||
|
self.queue_size = 0 |
||||
|
# Target cache size. Has little effect on sync time. |
||||
|
self.target_cache_size = 10 * 1024 * 1024 |
||||
|
self.fetched_height = height |
||||
|
self.recent_sizes = [0] |
||||
|
|
||||
|
async def get_blocks(self): |
||||
|
'''Returns a list of prefetched blocks.''' |
||||
|
blocks, total_size = await self.queue.get() |
||||
|
self.queue_size -= total_size |
||||
|
return blocks |
||||
|
|
||||
|
async def clear(self, height): |
||||
|
'''Clear prefetched blocks and restart from the given height. |
||||
|
|
||||
|
Used in blockchain reorganisations. This coroutine can be |
||||
|
called asynchronously to the _prefetch coroutine so we must |
||||
|
synchronize. |
||||
|
''' |
||||
|
with await self.semaphore: |
||||
|
while not self.queue.empty(): |
||||
|
self.queue.get_nowait() |
||||
|
self.queue_size = 0 |
||||
|
self.fetched_height = height |
||||
|
|
||||
|
async def start(self): |
||||
|
'''Loop forever polling for more blocks.''' |
||||
|
self.logger.info('prefetching blocks...') |
||||
|
while True: |
||||
|
while self.queue_size < self.target_cache_size: |
||||
|
try: |
||||
|
with await self.semaphore: |
||||
|
await self._prefetch() |
||||
|
except DaemonError as e: |
||||
|
self.logger.info('ignoring daemon errors: {}'.format(e)) |
||||
|
await asyncio.sleep(2) |
||||
|
|
||||
|
def _prefill_count(self, room): |
||||
|
ave_size = sum(self.recent_sizes) // len(self.recent_sizes) |
||||
|
count = room // ave_size if ave_size else 0 |
||||
|
return max(count, 10) |
||||
|
|
||||
|
async def _prefetch(self): |
||||
|
'''Prefetch blocks if there are any to prefetch.''' |
||||
|
daemon_height = await self.daemon.height() |
||||
|
max_count = min(daemon_height - self.fetched_height, 4000) |
||||
|
count = min(max_count, self._prefill_count(self.target_cache_size)) |
||||
|
first = self.fetched_height + 1 |
||||
|
hex_hashes = await self.daemon.block_hex_hashes(first, count) |
||||
|
if not hex_hashes: |
||||
|
return |
||||
|
|
||||
|
blocks = await self.daemon.raw_blocks(hex_hashes) |
||||
|
sizes = [len(block) for block in blocks] |
||||
|
total_size = sum(sizes) |
||||
|
self.queue.put_nowait((blocks, total_size)) |
||||
|
self.queue_size += total_size |
||||
|
self.fetched_height += len(blocks) |
||||
|
|
||||
|
# Keep 50 most recent block sizes for fetch count estimation |
||||
|
self.recent_sizes.extend(sizes) |
||||
|
excess = len(self.recent_sizes) - 50 |
||||
|
if excess > 0: |
||||
|
self.recent_sizes = self.recent_sizes[excess:] |
||||
|
|
||||
|
|
||||
|
class BlockProcessor(LoggedClass): |
||||
|
'''Process blocks and update the DB state to match. |
||||
|
|
||||
|
Employ a prefetcher to prefetch blocks in batches for processing. |
||||
|
Coordinate backing up in case of chain reorganisations. |
||||
|
''' |
||||
|
|
||||
|
def __init__(self, env, daemon): |
||||
|
super().__init__() |
||||
|
|
||||
|
self.daemon = daemon |
||||
|
|
||||
|
# Meta |
||||
|
self.utxo_MB = env.utxo_MB |
||||
|
self.hist_MB = env.hist_MB |
||||
|
self.next_cache_check = 0 |
||||
|
self.coin = env.coin |
||||
|
self.caught_up = False |
||||
|
self.reorg_limit = env.reorg_limit |
||||
|
|
||||
|
# Chain state (initialize to genesis in case of new DB) |
||||
|
self.db_height = -1 |
||||
|
self.db_tx_count = 0 |
||||
|
self.db_tip = b'\0' * 32 |
||||
|
self.flush_count = 0 |
||||
|
self.utxo_flush_count = 0 |
||||
|
self.wall_time = 0 |
||||
|
|
||||
|
# Open DB and metadata files. Record some of its state. |
||||
|
self.db = self.open_db(self.coin) |
||||
|
self.tx_count = self.db_tx_count |
||||
|
self.height = self.db_height |
||||
|
self.tip = self.db_tip |
||||
|
|
||||
|
# Caches to be flushed later. Headers and tx_hashes have one |
||||
|
# entry per block |
||||
|
self.history = defaultdict(partial(array.array, 'I')) |
||||
|
self.history_size = 0 |
||||
|
self.backup_hash168s = set() |
||||
|
self.utxo_cache = UTXOCache(self, self.db, self.coin) |
||||
|
self.fs_cache = FSCache(self.coin, self.height, self.tx_count) |
||||
|
self.prefetcher = Prefetcher(daemon, self.height) |
||||
|
|
||||
|
self.last_flush = time.time() |
||||
|
self.last_flush_tx_count = self.tx_count |
||||
|
|
||||
|
# Redirected member func |
||||
|
self.get_tx_hash = self.fs_cache.get_tx_hash |
||||
|
|
||||
|
# Log state |
||||
|
self.logger.info('{}/{} height: {:,d} tx count: {:,d} ' |
||||
|
'flush count: {:,d} utxo flush count: {:,d} ' |
||||
|
'sync time: {}' |
||||
|
.format(self.coin.NAME, self.coin.NET, self.height, |
||||
|
self.tx_count, self.flush_count, |
||||
|
self.utxo_flush_count, |
||||
|
formatted_time(self.wall_time))) |
||||
|
self.logger.info('reorg limit of {:,d} blocks' |
||||
|
.format(self.reorg_limit)) |
||||
|
self.logger.info('flushing UTXO cache at {:,d} MB' |
||||
|
.format(self.utxo_MB)) |
||||
|
self.logger.info('flushing history cache at {:,d} MB' |
||||
|
.format(self.hist_MB)) |
||||
|
|
||||
|
self.clean_db() |
||||
|
|
||||
|
def coros(self, force_backup=False): |
||||
|
if force_backup: |
||||
|
return [self.force_chain_reorg(True), self.prefetcher.start()] |
||||
|
else: |
||||
|
return [self.start(), self.prefetcher.start()] |
||||
|
|
||||
|
async def start(self): |
||||
|
'''External entry point for block processing. |
||||
|
|
||||
|
A simple wrapper that safely flushes the DB on clean |
||||
|
shutdown. |
||||
|
''' |
||||
|
try: |
||||
|
await self.advance_blocks() |
||||
|
finally: |
||||
|
self.flush(True) |
||||
|
|
||||
|
async def advance_blocks(self): |
||||
|
'''Loop forever processing blocks in the forward direction.''' |
||||
|
while True: |
||||
|
blocks = await self.prefetcher.get_blocks() |
||||
|
for block in blocks: |
||||
|
if not self.advance_block(block): |
||||
|
await self.handle_chain_reorg() |
||||
|
self.caught_up = False |
||||
|
break |
||||
|
await asyncio.sleep(0) # Yield |
||||
|
|
||||
|
if self.height != self.daemon.cached_height(): |
||||
|
continue |
||||
|
|
||||
|
if not self.caught_up: |
||||
|
self.caught_up = True |
||||
|
self.logger.info('caught up to height {:,d}' |
||||
|
.format(self.height)) |
||||
|
|
||||
|
# Flush everything when in caught-up state as queries |
||||
|
# are performed on DB not in-memory |
||||
|
self.flush(True) |
||||
|
|
||||
|
async def force_chain_reorg(self, to_genesis): |
||||
|
try: |
||||
|
await self.handle_chain_reorg(to_genesis) |
||||
|
finally: |
||||
|
self.flush(True) |
||||
|
|
||||
|
async def handle_chain_reorg(self, to_genesis=False): |
||||
|
# First get all state on disk |
||||
|
self.logger.info('chain reorg detected') |
||||
|
self.flush(True) |
||||
|
self.logger.info('finding common height...') |
||||
|
hashes = await self.reorg_hashes(to_genesis) |
||||
|
# Reverse and convert to hex strings. |
||||
|
hashes = [hash_to_str(hash) for hash in reversed(hashes)] |
||||
|
for hex_hashes in chunks(hashes, 50): |
||||
|
blocks = await self.daemon.raw_blocks(hex_hashes) |
||||
|
self.backup_blocks(blocks) |
||||
|
self.logger.info('backed up to height {:,d}'.format(self.height)) |
||||
|
await self.prefetcher.clear(self.height) |
||||
|
self.logger.info('prefetcher reset') |
||||
|
|
||||
|
async def reorg_hashes(self, to_genesis): |
||||
|
'''Return the list of hashes to back up beacuse of a reorg. |
||||
|
|
||||
|
The hashes are returned in order of increasing height.''' |
||||
|
def match_pos(hashes1, hashes2): |
||||
|
for n, (hash1, hash2) in enumerate(zip(hashes1, hashes2)): |
||||
|
if hash1 == hash2: |
||||
|
return n |
||||
|
return -1 |
||||
|
|
||||
|
start = self.height - 1 |
||||
|
count = 1 |
||||
|
while start > 0: |
||||
|
self.logger.info('start: {:,d} count: {:,d}'.format(start, count)) |
||||
|
hashes = self.fs_cache.block_hashes(start, count) |
||||
|
hex_hashes = [hash_to_str(hash) for hash in hashes] |
||||
|
d_hex_hashes = await self.daemon.block_hex_hashes(start, count) |
||||
|
n = match_pos(hex_hashes, d_hex_hashes) |
||||
|
if n >= 0 and not to_genesis: |
||||
|
start += n + 1 |
||||
|
break |
||||
|
count = min(count * 2, start) |
||||
|
start -= count |
||||
|
|
||||
|
# Hashes differ from height 'start' |
||||
|
count = (self.height - start) + 1 |
||||
|
|
||||
|
self.logger.info('chain was reorganised for {:,d} blocks from ' |
||||
|
'height {:,d} to height {:,d}' |
||||
|
.format(count, start, start + count - 1)) |
||||
|
|
||||
|
return self.fs_cache.block_hashes(start, count) |
||||
|
|
||||
|
def open_db(self, coin): |
||||
|
db_name = '{}-{}'.format(coin.NAME, coin.NET) |
||||
|
try: |
||||
|
db = plyvel.DB(db_name, create_if_missing=False, |
||||
|
error_if_exists=False, compression=None) |
||||
|
except: |
||||
|
db = plyvel.DB(db_name, create_if_missing=True, |
||||
|
error_if_exists=True, compression=None) |
||||
|
self.logger.info('created new database {}'.format(db_name)) |
||||
|
else: |
||||
|
self.logger.info('successfully opened database {}'.format(db_name)) |
||||
|
self.read_state(db) |
||||
|
|
||||
|
return db |
||||
|
|
||||
|
def read_state(self, db): |
||||
|
state = db.get(b'state') |
||||
|
state = ast.literal_eval(state.decode()) |
||||
|
if state['genesis'] != self.coin.GENESIS_HASH: |
||||
|
raise ChainError('DB genesis hash {} does not match coin {}' |
||||
|
.format(state['genesis_hash'], |
||||
|
self.coin.GENESIS_HASH)) |
||||
|
self.db_height = state['height'] |
||||
|
self.db_tx_count = state['tx_count'] |
||||
|
self.db_tip = state['tip'] |
||||
|
self.flush_count = state['flush_count'] |
||||
|
self.utxo_flush_count = state['utxo_flush_count'] |
||||
|
self.wall_time = state['wall_time'] |
||||
|
|
||||
|
def clean_db(self): |
||||
|
'''Clean out stale DB items. |
||||
|
|
||||
|
Stale DB items are excess history flushed since the most |
||||
|
recent UTXO flush (only happens on unclean shutdown), and aged |
||||
|
undo information. |
||||
|
''' |
||||
|
if self.flush_count < self.utxo_flush_count: |
||||
|
raise ChainError('DB corrupt: flush_count < utxo_flush_count') |
||||
|
with self.db.write_batch(transaction=True) as batch: |
||||
|
if self.flush_count > self.utxo_flush_count: |
||||
|
self.logger.info('DB shut down uncleanly. Scanning for ' |
||||
|
'excess history flushes...') |
||||
|
self.remove_excess_history(batch) |
||||
|
self.utxo_flush_count = self.flush_count |
||||
|
self.remove_stale_undo_items(batch) |
||||
|
self.flush_state(batch) |
||||
|
|
||||
|
def remove_excess_history(self, batch): |
||||
|
prefix = b'H' |
||||
|
unpack = struct.unpack |
||||
|
keys = [] |
||||
|
for key, hist in self.db.iterator(prefix=prefix): |
||||
|
flush_id, = unpack('>H', key[-2:]) |
||||
|
if flush_id > self.utxo_flush_count: |
||||
|
keys.append(key) |
||||
|
|
||||
|
self.logger.info('deleting {:,d} history entries' |
||||
|
.format(len(keys))) |
||||
|
for key in keys: |
||||
|
batch.delete(key) |
||||
|
|
||||
|
def remove_stale_undo_items(self, batch): |
||||
|
prefix = b'U' |
||||
|
unpack = struct.unpack |
||||
|
cutoff = self.db_height - self.reorg_limit |
||||
|
keys = [] |
||||
|
for key, hist in self.db.iterator(prefix=prefix): |
||||
|
height, = unpack('>I', key[-4:]) |
||||
|
if height > cutoff: |
||||
|
break |
||||
|
keys.append(key) |
||||
|
|
||||
|
self.logger.info('deleting {:,d} stale undo entries' |
||||
|
.format(len(keys))) |
||||
|
for key in keys: |
||||
|
batch.delete(key) |
||||
|
|
||||
|
def flush_state(self, batch): |
||||
|
'''Flush chain state to the batch.''' |
||||
|
now = time.time() |
||||
|
self.wall_time += now - self.last_flush |
||||
|
self.last_flush = now |
||||
|
self.last_flush_tx_count = self.tx_count |
||||
|
state = { |
||||
|
'genesis': self.coin.GENESIS_HASH, |
||||
|
'height': self.db_height, |
||||
|
'tx_count': self.db_tx_count, |
||||
|
'tip': self.db_tip, |
||||
|
'flush_count': self.flush_count, |
||||
|
'utxo_flush_count': self.utxo_flush_count, |
||||
|
'wall_time': self.wall_time, |
||||
|
} |
||||
|
batch.put(b'state', repr(state).encode()) |
||||
|
|
||||
|
def flush_utxos(self, batch): |
||||
|
self.logger.info('flushing UTXOs: {:,d} txs and {:,d} blocks' |
||||
|
.format(self.tx_count - self.db_tx_count, |
||||
|
self.height - self.db_height)) |
||||
|
self.utxo_cache.flush(batch) |
||||
|
self.utxo_flush_count = self.flush_count |
||||
|
self.db_tx_count = self.tx_count |
||||
|
self.db_height = self.height |
||||
|
self.db_tip = self.tip |
||||
|
|
||||
|
def assert_flushed(self): |
||||
|
'''Asserts state is fully flushed.''' |
||||
|
assert self.tx_count == self.db_tx_count |
||||
|
assert not self.history |
||||
|
assert not self.utxo_cache.cache |
||||
|
assert not self.utxo_cache.db_cache |
||||
|
assert not self.backup_hash168s |
||||
|
|
||||
|
def flush(self, flush_utxos=False): |
||||
|
'''Flush out cached state. |
||||
|
|
||||
|
History is always flushed. UTXOs are flushed if flush_utxos.''' |
||||
|
if self.height == self.db_height: |
||||
|
self.logger.info('nothing to flush') |
||||
|
self.assert_flushed() |
||||
|
return |
||||
|
|
||||
|
flush_start = time.time() |
||||
|
last_flush = self.last_flush |
||||
|
tx_diff = self.tx_count - self.last_flush_tx_count |
||||
|
|
||||
|
# Write out the files to the FS before flushing to the DB. If |
||||
|
# the DB transaction fails, the files being too long doesn't |
||||
|
# matter. But if writing the files fails we do not want to |
||||
|
# have updated the DB. |
||||
|
if self.height > self.db_height: |
||||
|
self.fs_cache.flush(self.height, self.tx_count) |
||||
|
|
||||
|
with self.db.write_batch(transaction=True) as batch: |
||||
|
# History first - fast and frees memory. Flush state last |
||||
|
# as it reads the wall time. |
||||
|
if self.height > self.db_height: |
||||
|
self.flush_history(batch) |
||||
|
else: |
||||
|
self.backup_history(batch) |
||||
|
if flush_utxos: |
||||
|
self.flush_utxos(batch) |
||||
|
self.flush_state(batch) |
||||
|
self.logger.info('committing transaction...') |
||||
|
|
||||
|
# Update and put the wall time again - otherwise we drop the |
||||
|
# time it took to commit the batch |
||||
|
self.flush_state(self.db) |
||||
|
|
||||
|
flush_time = int(self.last_flush - flush_start) |
||||
|
self.logger.info('flush #{:,d} to height {:,d} txs: {:,d} took {:,d}s' |
||||
|
.format(self.flush_count, self.height, self.tx_count, |
||||
|
flush_time)) |
||||
|
|
||||
|
# Catch-up stats |
||||
|
if not self.caught_up and tx_diff > 0: |
||||
|
daemon_height = self.daemon.cached_height() |
||||
|
txs_per_sec = int(self.tx_count / self.wall_time) |
||||
|
this_txs_per_sec = 1 + int(tx_diff / (self.last_flush - last_flush)) |
||||
|
if self.height > self.coin.TX_COUNT_HEIGHT: |
||||
|
tx_est = (daemon_height - self.height) * self.coin.TX_PER_BLOCK |
||||
|
else: |
||||
|
tx_est = ((daemon_height - self.coin.TX_COUNT_HEIGHT) |
||||
|
* self.coin.TX_PER_BLOCK |
||||
|
+ (self.coin.TX_COUNT - self.tx_count)) |
||||
|
|
||||
|
self.logger.info('tx/sec since genesis: {:,d}, ' |
||||
|
'since last flush: {:,d}' |
||||
|
.format(txs_per_sec, this_txs_per_sec)) |
||||
|
self.logger.info('sync time: {} ETA: {}' |
||||
|
.format(formatted_time(self.wall_time), |
||||
|
formatted_time(tx_est / this_txs_per_sec))) |
||||
|
|
||||
|
def flush_history(self, batch): |
||||
|
self.logger.info('flushing history') |
||||
|
assert not self.backup_hash168s |
||||
|
|
||||
|
self.flush_count += 1 |
||||
|
flush_id = struct.pack('>H', self.flush_count) |
||||
|
|
||||
|
for hash168, hist in self.history.items(): |
||||
|
key = b'H' + hash168 + flush_id |
||||
|
batch.put(key, hist.tobytes()) |
||||
|
|
||||
|
self.logger.info('{:,d} history entries in {:,d} addrs' |
||||
|
.format(self.history_size, len(self.history))) |
||||
|
|
||||
|
self.history = defaultdict(partial(array.array, 'I')) |
||||
|
self.history_size = 0 |
||||
|
|
||||
|
def backup_history(self, batch): |
||||
|
self.logger.info('backing up history to height {:,d} tx_count {:,d}' |
||||
|
.format(self.height, self.tx_count)) |
||||
|
|
||||
|
# Drop any NO_CACHE entry |
||||
|
self.backup_hash168s.discard(NO_CACHE_ENTRY) |
||||
|
assert not self.history |
||||
|
|
||||
|
nremoves = 0 |
||||
|
for hash168 in sorted(self.backup_hash168s): |
||||
|
prefix = b'H' + hash168 |
||||
|
deletes = [] |
||||
|
puts = {} |
||||
|
for key, hist in self.db.iterator(reverse=True, prefix=prefix): |
||||
|
a = array.array('I') |
||||
|
a.frombytes(hist) |
||||
|
# Remove all history entries >= self.tx_count |
||||
|
idx = bisect_left(a, self.tx_count) |
||||
|
nremoves += len(a) - idx |
||||
|
if idx > 0: |
||||
|
puts[key] = a[:idx].tobytes() |
||||
|
break |
||||
|
deletes.append(key) |
||||
|
|
||||
|
for key in deletes: |
||||
|
batch.delete(key) |
||||
|
for key, value in puts.items(): |
||||
|
batch.put(key, value) |
||||
|
|
||||
|
self.logger.info('removed {:,d} history entries from {:,d} addresses' |
||||
|
.format(nremoves, len(self.backup_hash168s))) |
||||
|
self.backup_hash168s = set() |
||||
|
|
||||
|
def cache_sizes(self): |
||||
|
'''Returns the approximate size of the cache, in MB.''' |
||||
|
# Good average estimates based on traversal of subobjects and |
||||
|
# requesting size from Python (see deep_getsizeof). For |
||||
|
# whatever reason Python O/S mem usage is typically +30% or |
||||
|
# more, so we scale our already bloated object sizes. |
||||
|
one_MB = int(1048576 / 1.3) |
||||
|
utxo_cache_size = len(self.utxo_cache.cache) * 187 |
||||
|
db_cache_size = len(self.utxo_cache.db_cache) * 105 |
||||
|
hist_cache_size = len(self.history) * 180 + self.history_size * 4 |
||||
|
utxo_MB = (db_cache_size + utxo_cache_size) // one_MB |
||||
|
hist_MB = hist_cache_size // one_MB |
||||
|
|
||||
|
self.logger.info('cache stats at height {:,d} daemon height: {:,d}' |
||||
|
.format(self.height, self.daemon.cached_height())) |
||||
|
self.logger.info(' entries: UTXO: {:,d} DB: {:,d} ' |
||||
|
'hist addrs: {:,d} hist size {:,d}' |
||||
|
.format(len(self.utxo_cache.cache), |
||||
|
len(self.utxo_cache.db_cache), |
||||
|
len(self.history), |
||||
|
self.history_size)) |
||||
|
self.logger.info(' size: {:,d}MB (UTXOs {:,d}MB hist {:,d}MB)' |
||||
|
.format(utxo_MB + hist_MB, utxo_MB, hist_MB)) |
||||
|
return utxo_MB, hist_MB |
||||
|
|
||||
|
def undo_key(self, height): |
||||
|
'''DB key for undo information at the given height.''' |
||||
|
return b'U' + struct.pack('>I', height) |
||||
|
|
||||
|
def write_undo_info(self, height, undo_info): |
||||
|
'''Write out undo information for the current height.''' |
||||
|
self.db.put(self.undo_key(height), undo_info) |
||||
|
|
||||
|
def read_undo_info(self, height): |
||||
|
'''Read undo information from a file for the current height.''' |
||||
|
return self.db.get(self.undo_key(height)) |
||||
|
|
||||
|
def advance_block(self, block): |
||||
|
# We must update the fs_cache before calling advance_txs() as |
||||
|
# the UTXO cache uses the fs_cache via get_tx_hash() to |
||||
|
# resolve compressed key collisions |
||||
|
header, tx_hashes, txs = self.coin.read_block(block) |
||||
|
self.fs_cache.advance_block(header, tx_hashes, txs) |
||||
|
prev_hash, header_hash = self.coin.header_hashes(header) |
||||
|
if prev_hash != self.tip: |
||||
|
return False |
||||
|
|
||||
|
self.tip = header_hash |
||||
|
self.height += 1 |
||||
|
undo_info = self.advance_txs(tx_hashes, txs) |
||||
|
if self.daemon.cached_height() - self.height <= self.reorg_limit: |
||||
|
self.write_undo_info(self.height, b''.join(undo_info)) |
||||
|
|
||||
|
# Check if we're getting full and time to flush? |
||||
|
now = time.time() |
||||
|
if now > self.next_cache_check: |
||||
|
self.next_cache_check = now + 60 |
||||
|
utxo_MB, hist_MB = self.cache_sizes() |
||||
|
if utxo_MB >= self.utxo_MB or hist_MB >= self.hist_MB: |
||||
|
self.flush(utxo_MB >= self.utxo_MB) |
||||
|
|
||||
|
return True |
||||
|
|
||||
|
def advance_txs(self, tx_hashes, txs): |
||||
|
put_utxo = self.utxo_cache.put |
||||
|
spend_utxo = self.utxo_cache.spend |
||||
|
undo_info = [] |
||||
|
|
||||
|
# Use local vars for speed in the loops |
||||
|
history = self.history |
||||
|
tx_num = self.tx_count |
||||
|
coin = self.coin |
||||
|
parse_script = ScriptPubKey.from_script |
||||
|
pack = struct.pack |
||||
|
|
||||
|
for tx, tx_hash in zip(txs, tx_hashes): |
||||
|
hash168s = set() |
||||
|
tx_numb = pack('<I', tx_num) |
||||
|
|
||||
|
# Spend the inputs |
||||
|
if not tx.is_coinbase: |
||||
|
for txin in tx.inputs: |
||||
|
cache_value = spend_utxo(txin.prev_hash, txin.prev_idx) |
||||
|
undo_info.append(cache_value) |
||||
|
hash168s.add(cache_value[:21]) |
||||
|
|
||||
|
# Add the new UTXOs |
||||
|
for idx, txout in enumerate(tx.outputs): |
||||
|
# Get the hash168. Ignore scripts we can't grok. |
||||
|
hash168 = parse_script(txout.pk_script, coin).hash168 |
||||
|
if hash168: |
||||
|
hash168s.add(hash168) |
||||
|
put_utxo(tx_hash + pack('<H', idx), |
||||
|
hash168 + tx_numb + pack('<Q', txout.value)) |
||||
|
|
||||
|
# Drop any NO_CACHE entry |
||||
|
hash168s.discard(NO_CACHE_ENTRY) |
||||
|
for hash168 in hash168s: |
||||
|
history[hash168].append(tx_num) |
||||
|
self.history_size += len(hash168s) |
||||
|
tx_num += 1 |
||||
|
|
||||
|
self.tx_count = tx_num |
||||
|
|
||||
|
return undo_info |
||||
|
|
||||
|
def backup_blocks(self, blocks): |
||||
|
'''Backup the blocks and flush. |
||||
|
|
||||
|
The blocks should be in order of decreasing height. |
||||
|
A flush is performed once the blocks are backed up. |
||||
|
''' |
||||
|
self.logger.info('backing up {:,d} blocks'.format(len(blocks))) |
||||
|
self.assert_flushed() |
||||
|
|
||||
|
for block in blocks: |
||||
|
header, tx_hashes, txs = self.coin.read_block(block) |
||||
|
prev_hash, header_hash = self.coin.header_hashes(header) |
||||
|
if header_hash != self.tip: |
||||
|
raise ChainError('backup block {} is not tip {} at height {:,d}' |
||||
|
.format(hash_to_str(header_hash), |
||||
|
hash_to_str(self.tip), self.height)) |
||||
|
|
||||
|
self.backup_txs(tx_hashes, txs) |
||||
|
self.fs_cache.backup_block() |
||||
|
self.tip = prev_hash |
||||
|
self.height -= 1 |
||||
|
|
||||
|
self.logger.info('backed up to height {:,d}'.format(self.height)) |
||||
|
self.flush(True) |
||||
|
|
||||
|
def backup_txs(self, tx_hashes, txs): |
||||
|
# Prevout values, in order down the block (coinbase first if present) |
||||
|
# undo_info is in reverse block order |
||||
|
undo_info = self.read_undo_info(self.height) |
||||
|
n = len(undo_info) |
||||
|
|
||||
|
# Use local vars for speed in the loops |
||||
|
pack = struct.pack |
||||
|
put_utxo = self.utxo_cache.put |
||||
|
spend_utxo = self.utxo_cache.spend |
||||
|
hash168s = self.backup_hash168s |
||||
|
|
||||
|
rtxs = reversed(txs) |
||||
|
rtx_hashes = reversed(tx_hashes) |
||||
|
|
||||
|
for tx_hash, tx in zip(rtx_hashes, rtxs): |
||||
|
# Spend the outputs |
||||
|
for idx, txout in enumerate(tx.outputs): |
||||
|
cache_value = spend_utxo(tx_hash, idx) |
||||
|
hash168s.add(cache_value[:21]) |
||||
|
|
||||
|
# Restore the inputs |
||||
|
if not tx.is_coinbase: |
||||
|
for txin in reversed(tx.inputs): |
||||
|
n -= 33 |
||||
|
undo_item = undo_info[n:n+33] |
||||
|
put_utxo(txin.prev_hash + pack('<H', txin.prev_idx), |
||||
|
undo_item) |
||||
|
hash168s.add(undo_item[:21]) |
||||
|
|
||||
|
assert n == 0 |
||||
|
self.tx_count -= len(txs) |
||||
|
|
||||
|
@staticmethod |
||||
|
def resolve_limit(limit): |
||||
|
if limit is None: |
||||
|
return -1 |
||||
|
assert isinstance(limit, int) and limit >= 0 |
||||
|
return limit |
||||
|
|
||||
|
def get_history(self, hash168, limit=1000): |
||||
|
'''Generator that returns an unpruned, sorted list of (tx_hash, |
||||
|
height) tuples of transactions that touched the address, |
||||
|
earliest in the blockchain first. Includes both spending and |
||||
|
receiving transactions. By default yields at most 1000 entries. |
||||
|
Set limit to None to get them all. |
||||
|
''' |
||||
|
limit = self.resolve_limit(limit) |
||||
|
prefix = b'H' + hash168 |
||||
|
for key, hist in self.db.iterator(prefix=prefix): |
||||
|
a = array.array('I') |
||||
|
a.frombytes(hist) |
||||
|
for tx_num in a: |
||||
|
if limit == 0: |
||||
|
return |
||||
|
yield self.get_tx_hash(tx_num) |
||||
|
limit -= 1 |
||||
|
|
||||
|
def get_balance(self, hash168): |
||||
|
'''Returns the confirmed balance of an address.''' |
||||
|
return sum(utxo.value for utxo in self.get_utxos(hash168, limit=None)) |
||||
|
|
||||
|
def get_utxos(self, hash168, limit=1000): |
||||
|
'''Generator that yields all UTXOs for an address sorted in no |
||||
|
particular order. By default yields at most 1000 entries. |
||||
|
Set limit to None to get them all. |
||||
|
''' |
||||
|
limit = self.resolve_limit(limit) |
||||
|
unpack = struct.unpack |
||||
|
prefix = b'u' + hash168 |
||||
|
utxos = [] |
||||
|
for k, v in self.db.iterator(prefix=prefix): |
||||
|
(tx_pos, ) = unpack('<H', k[-2:]) |
||||
|
|
||||
|
for n in range(0, len(v), 12): |
||||
|
if limit == 0: |
||||
|
return |
||||
|
(tx_num, ) = unpack('<I', v[n:n+4]) |
||||
|
(value, ) = unpack('<Q', v[n+4:n+12]) |
||||
|
tx_hash, height = self.get_tx_hash(tx_num) |
||||
|
yield UTXO(tx_num, tx_pos, tx_hash, height, value) |
||||
|
limit -= 1 |
||||
|
|
||||
|
def get_utxos_sorted(self, hash168): |
||||
|
'''Returns all the UTXOs for an address sorted by height and |
||||
|
position in the block.''' |
||||
|
return sorted(self.get_utxos(hash168, limit=None)) |
||||
|
|
||||
|
def get_current_header(self): |
||||
|
'''Returns the current header as a dictionary.''' |
||||
|
return self.fs_cache.encode_header(self.height) |
@ -0,0 +1,400 @@ |
|||||
|
# See the file "LICENSE" for information about the copyright |
||||
|
# and warranty status of this software. |
||||
|
|
||||
|
import array |
||||
|
import itertools |
||||
|
import os |
||||
|
import struct |
||||
|
from bisect import bisect_right |
||||
|
|
||||
|
from lib.util import chunks, LoggedClass |
||||
|
from lib.hash import double_sha256, hash_to_str |
||||
|
|
||||
|
|
||||
|
# History can hold approx. 65536 * HIST_ENTRIES_PER_KEY entries |
||||
|
HIST_ENTRIES_PER_KEY = 1024 |
||||
|
HIST_VALUE_BYTES = HIST_ENTRIES_PER_KEY * 4 |
||||
|
ADDR_TX_HASH_LEN = 4 |
||||
|
UTXO_TX_HASH_LEN = 4 |
||||
|
NO_HASH_168 = bytes([255]) * 21 |
||||
|
NO_CACHE_ENTRY = NO_HASH_168 + bytes(12) |
||||
|
|
||||
|
|
||||
|
class UTXOCache(LoggedClass): |
||||
|
'''An in-memory UTXO cache, representing all changes to UTXO state |
||||
|
since the last DB flush. |
||||
|
|
||||
|
We want to store millions, perhaps 10s of millions of these in |
||||
|
memory for optimal performance during initial sync, because then |
||||
|
it is possible to spend UTXOs without ever going to the database |
||||
|
(other than as an entry in the address history, and there is only |
||||
|
one such entry per TX not per UTXO). So store them in a Python |
||||
|
dictionary with binary keys and values. |
||||
|
|
||||
|
Key: TX_HASH + TX_IDX (32 + 2 = 34 bytes) |
||||
|
Value: HASH168 + TX_NUM + VALUE (21 + 4 + 8 = 33 bytes) |
||||
|
|
||||
|
That's 67 bytes of raw data. Python dictionary overhead means |
||||
|
each entry actually uses about 187 bytes of memory. So almost |
||||
|
11.5 million UTXOs can fit in 2GB of RAM. There are approximately |
||||
|
42 million UTXOs on bitcoin mainnet at height 433,000. |
||||
|
|
||||
|
Semantics: |
||||
|
|
||||
|
add: Add it to the cache dictionary. |
||||
|
spend: Remove it if in the cache dictionary. |
||||
|
Otherwise it's been flushed to the DB. Each UTXO |
||||
|
is responsible for two entries in the DB stored using |
||||
|
compressed keys. Mark both for deletion in the next |
||||
|
flush of the in-memory UTXO cache. |
||||
|
|
||||
|
A UTXO is stored in the DB in 2 "tables": |
||||
|
|
||||
|
1. The output value and tx number. Must be keyed with a |
||||
|
hash168 prefix so the unspent outputs and balance of an |
||||
|
arbitrary address can be looked up with a simple key |
||||
|
traversal. |
||||
|
Key: b'u' + hash168 + compressed_tx_hash + tx_idx |
||||
|
Value: a (tx_num, value) pair |
||||
|
|
||||
|
2. Given a prevout, we need to be able to look up the UTXO key |
||||
|
to remove it. As is keyed by hash168 and that is not part |
||||
|
of the prevout, we need a hash168 lookup. |
||||
|
Key: b'h' + compressed tx_hash + tx_idx |
||||
|
Value: (hash168, tx_num) pair |
||||
|
|
||||
|
The compressed TX hash is just the first few bytes of the hash of |
||||
|
the TX the UTXO is in (and needn't be the same number of bytes in |
||||
|
each table). As this is not unique there will be collisions; |
||||
|
tx_num is stored to resolve them. The collision rate is around |
||||
|
0.02% for the hash168 table, and almost zero for the UTXO table |
||||
|
(there are around 100 collisions in the whole bitcoin blockchain). |
||||
|
|
||||
|
''' |
||||
|
|
||||
|
def __init__(self, parent, db, coin): |
||||
|
super().__init__() |
||||
|
self.parent = parent |
||||
|
self.coin = coin |
||||
|
self.cache = {} |
||||
|
self.put = self.cache.__setitem__ |
||||
|
self.db = db |
||||
|
self.db_cache = {} |
||||
|
# Statistics |
||||
|
self.cache_spends = 0 |
||||
|
self.db_deletes = 0 |
||||
|
|
||||
|
def spend(self, prev_hash, prev_idx): |
||||
|
'''Spend a UTXO and return the cache's value. |
||||
|
|
||||
|
If the UTXO is not in the cache it must be on disk. |
||||
|
''' |
||||
|
# Fast track is it's in the cache |
||||
|
pack = struct.pack |
||||
|
idx_packed = pack('<H', prev_idx) |
||||
|
value = self.cache.pop(prev_hash + idx_packed, None) |
||||
|
if value: |
||||
|
self.cache_spends += 1 |
||||
|
return value |
||||
|
|
||||
|
# Oh well. Find and remove it from the DB. |
||||
|
hash168 = self.hash168(prev_hash, idx_packed) |
||||
|
if not hash168: |
||||
|
return NO_CACHE_ENTRY |
||||
|
|
||||
|
self.db_deletes += 1 |
||||
|
|
||||
|
# Read the UTXO through the cache from the disk. We have to |
||||
|
# go through the cache because compressed keys can collide. |
||||
|
key = b'u' + hash168 + prev_hash[:UTXO_TX_HASH_LEN] + idx_packed |
||||
|
data = self.cache_get(key) |
||||
|
if data is None: |
||||
|
# Uh-oh, this should not happen... |
||||
|
self.logger.error('found no UTXO for {} / {:d} key {}' |
||||
|
.format(hash_to_str(prev_hash), prev_idx, |
||||
|
bytes(key).hex())) |
||||
|
return NO_CACHE_ENTRY |
||||
|
|
||||
|
if len(data) == 12: |
||||
|
self.cache_delete(key) |
||||
|
return hash168 + data |
||||
|
|
||||
|
# Resolve the compressed key collison. These should be |
||||
|
# extremely rare. |
||||
|
assert len(data) % 12 == 0 |
||||
|
for n in range(0, len(data), 12): |
||||
|
(tx_num, ) = struct.unpack('<I', data[n:n+4]) |
||||
|
tx_hash, height = self.parent.get_tx_hash(tx_num) |
||||
|
if prev_hash == tx_hash: |
||||
|
result = hash168 + data[n: n+12] |
||||
|
data = data[:n] + data[n+12:] |
||||
|
self.cache_write(key, data) |
||||
|
return result |
||||
|
|
||||
|
raise Exception('could not resolve UTXO key collision') |
||||
|
|
||||
|
def hash168(self, tx_hash, idx_packed): |
||||
|
'''Return the hash168 paid to by the given TXO. |
||||
|
|
||||
|
Refers to the database. Returns None if not found (which is |
||||
|
indicates a non-standard script). |
||||
|
''' |
||||
|
key = b'h' + tx_hash[:ADDR_TX_HASH_LEN] + idx_packed |
||||
|
data = self.cache_get(key) |
||||
|
if data is None: |
||||
|
# Assuming the DB is not corrupt, this indicates a |
||||
|
# successful spend of a non-standard script |
||||
|
return None |
||||
|
|
||||
|
if len(data) == 25: |
||||
|
self.cache_delete(key) |
||||
|
return data[:21] |
||||
|
|
||||
|
assert len(data) % 25 == 0 |
||||
|
|
||||
|
# Resolve the compressed key collision using the TX number |
||||
|
for n in range(0, len(data), 25): |
||||
|
(tx_num, ) = struct.unpack('<I', data[n+21:n+25]) |
||||
|
my_hash, height = self.parent.get_tx_hash(tx_num) |
||||
|
if my_hash == tx_hash: |
||||
|
self.cache_write(key, data[:n] + data[n+25:]) |
||||
|
return data[n:n+21] |
||||
|
|
||||
|
raise Exception('could not resolve hash168 collision') |
||||
|
|
||||
|
def cache_write(self, key, value): |
||||
|
'''Cache write of a (key, value) pair to the DB.''' |
||||
|
assert(bool(value)) |
||||
|
self.db_cache[key] = value |
||||
|
|
||||
|
def cache_delete(self, key): |
||||
|
'''Cache deletion of a key from the DB.''' |
||||
|
self.db_cache[key] = None |
||||
|
|
||||
|
def cache_get(self, key): |
||||
|
'''Fetch a value from the DB through our write cache.''' |
||||
|
value = self.db_cache.get(key) |
||||
|
if value: |
||||
|
return value |
||||
|
return self.db.get(key) |
||||
|
|
||||
|
def flush(self, batch): |
||||
|
'''Flush the cached DB writes and UTXO set to the batch.''' |
||||
|
# Care is needed because the writes generated by flushing the |
||||
|
# UTXO state may have keys in common with our write cache or |
||||
|
# may be in the DB already. |
||||
|
hcolls = ucolls = 0 |
||||
|
new_utxos = len(self.cache) |
||||
|
|
||||
|
for cache_key, cache_value in self.cache.items(): |
||||
|
# Frist write to the hash168 lookup table |
||||
|
key = b'h' + cache_key[:ADDR_TX_HASH_LEN] + cache_key[-2:] |
||||
|
value = cache_value[:25] |
||||
|
prior_value = self.cache_get(key) |
||||
|
if prior_value: # Should rarely happen |
||||
|
hcolls += 1 |
||||
|
value += prior_value |
||||
|
self.cache_write(key, value) |
||||
|
|
||||
|
# Next write the UTXO table |
||||
|
key = (b'u' + cache_value[:21] + cache_key[:UTXO_TX_HASH_LEN] |
||||
|
+ cache_key[-2:]) |
||||
|
value = cache_value[-12:] |
||||
|
prior_value = self.cache_get(key) |
||||
|
if prior_value: # Should almost never happen |
||||
|
ucolls += 1 |
||||
|
value += prior_value |
||||
|
self.cache_write(key, value) |
||||
|
|
||||
|
# GC-ing this now can only help the levelDB write. |
||||
|
self.cache = {} |
||||
|
self.put = self.cache.__setitem__ |
||||
|
|
||||
|
# Now we can update to the batch. |
||||
|
for key, value in self.db_cache.items(): |
||||
|
if value: |
||||
|
batch.put(key, value) |
||||
|
else: |
||||
|
batch.delete(key) |
||||
|
|
||||
|
self.db_cache = {} |
||||
|
|
||||
|
adds = new_utxos + self.cache_spends |
||||
|
|
||||
|
self.logger.info('UTXO cache adds: {:,d} spends: {:,d} ' |
||||
|
.format(adds, self.cache_spends)) |
||||
|
self.logger.info('UTXO DB adds: {:,d} spends: {:,d}. ' |
||||
|
'Collisions: hash168: {:,d} UTXO: {:,d}' |
||||
|
.format(new_utxos, self.db_deletes, |
||||
|
hcolls, ucolls)) |
||||
|
self.cache_spends = self.db_deletes = 0 |
||||
|
|
||||
|
|
||||
|
class FSCache(LoggedClass): |
||||
|
|
||||
|
def __init__(self, coin, height, tx_count): |
||||
|
super().__init__() |
||||
|
|
||||
|
self.coin = coin |
||||
|
self.tx_hash_file_size = 16 * 1024 * 1024 |
||||
|
assert self.tx_hash_file_size % 32 == 0 |
||||
|
|
||||
|
# On-disk values, updated by a flush |
||||
|
self.height = height |
||||
|
|
||||
|
# Unflushed items |
||||
|
self.headers = [] |
||||
|
self.tx_hashes = [] |
||||
|
|
||||
|
is_new = height == -1 |
||||
|
self.headers_file = self.open_file('headers', is_new) |
||||
|
self.txcount_file = self.open_file('txcount', is_new) |
||||
|
|
||||
|
# tx_counts[N] has the cumulative number of txs at the end of |
||||
|
# height N. So tx_counts[0] is 1 - the genesis coinbase |
||||
|
self.tx_counts = array.array('I') |
||||
|
self.txcount_file.seek(0) |
||||
|
self.tx_counts.fromfile(self.txcount_file, self.height + 1) |
||||
|
if self.tx_counts: |
||||
|
assert tx_count == self.tx_counts[-1] |
||||
|
else: |
||||
|
assert tx_count == 0 |
||||
|
|
||||
|
def open_file(self, filename, create=False): |
||||
|
'''Open the file name. Return its handle.''' |
||||
|
try: |
||||
|
return open(filename, 'rb+') |
||||
|
except FileNotFoundError: |
||||
|
if create: |
||||
|
return open(filename, 'wb+') |
||||
|
raise |
||||
|
|
||||
|
def advance_block(self, header, tx_hashes, txs): |
||||
|
'''Update the FS cache for a new block.''' |
||||
|
prior_tx_count = self.tx_counts[-1] if self.tx_counts else 0 |
||||
|
|
||||
|
# Cache the new header, tx hashes and cumulative tx count |
||||
|
self.headers.append(header) |
||||
|
self.tx_hashes.append(tx_hashes) |
||||
|
self.tx_counts.append(prior_tx_count + len(txs)) |
||||
|
|
||||
|
def backup_block(self): |
||||
|
'''Revert a block.''' |
||||
|
assert not self.headers |
||||
|
assert not self.tx_hashes |
||||
|
assert self.height >= 0 |
||||
|
# Just update in-memory. It doesn't matter if disk files are |
||||
|
# too long, they will be overwritten when advancing. |
||||
|
self.height -= 1 |
||||
|
self.tx_counts.pop() |
||||
|
|
||||
|
def flush(self, new_height, new_tx_count): |
||||
|
'''Flush the things stored on the filesystem. |
||||
|
The arguments are passed for sanity check assertions only.''' |
||||
|
self.logger.info('flushing to file system') |
||||
|
|
||||
|
blocks_done = len(self.headers) |
||||
|
prior_tx_count = self.tx_counts[self.height] if self.height >= 0 else 0 |
||||
|
cur_tx_count = self.tx_counts[-1] if self.tx_counts else 0 |
||||
|
txs_done = cur_tx_count - prior_tx_count |
||||
|
|
||||
|
assert self.height + blocks_done == new_height |
||||
|
assert len(self.tx_hashes) == blocks_done |
||||
|
assert len(self.tx_counts) == new_height + 1 |
||||
|
assert cur_tx_count == new_tx_count, \ |
||||
|
'cur: {:,d} new: {:,d}'.format(cur_tx_count, new_tx_count) |
||||
|
|
||||
|
# First the headers |
||||
|
headers = b''.join(self.headers) |
||||
|
header_len = self.coin.HEADER_LEN |
||||
|
self.headers_file.seek((self.height + 1) * header_len) |
||||
|
self.headers_file.write(headers) |
||||
|
self.headers_file.flush() |
||||
|
|
||||
|
# Then the tx counts |
||||
|
self.txcount_file.seek((self.height + 1) * self.tx_counts.itemsize) |
||||
|
self.txcount_file.write(self.tx_counts[self.height + 1:]) |
||||
|
self.txcount_file.flush() |
||||
|
|
||||
|
# Finally the hashes |
||||
|
hashes = memoryview(b''.join(itertools.chain(*self.tx_hashes))) |
||||
|
assert len(hashes) % 32 == 0 |
||||
|
assert len(hashes) // 32 == txs_done |
||||
|
cursor = 0 |
||||
|
file_pos = prior_tx_count * 32 |
||||
|
while cursor < len(hashes): |
||||
|
file_num, offset = divmod(file_pos, self.tx_hash_file_size) |
||||
|
size = min(len(hashes) - cursor, self.tx_hash_file_size - offset) |
||||
|
filename = 'hashes{:04d}'.format(file_num) |
||||
|
with self.open_file(filename, create=True) as f: |
||||
|
f.seek(offset) |
||||
|
f.write(hashes[cursor:cursor + size]) |
||||
|
cursor += size |
||||
|
file_pos += size |
||||
|
|
||||
|
os.sync() |
||||
|
|
||||
|
self.tx_hashes = [] |
||||
|
self.headers = [] |
||||
|
self.height += blocks_done |
||||
|
|
||||
|
def read_headers(self, height, count): |
||||
|
read_count = min(count, self.height + 1 - height) |
||||
|
|
||||
|
assert height >= 0 and read_count >= 0 |
||||
|
assert count <= read_count + len(self.headers) |
||||
|
|
||||
|
result = b'' |
||||
|
if read_count > 0: |
||||
|
header_len = self.coin.HEADER_LEN |
||||
|
self.headers_file.seek(height * header_len) |
||||
|
result = self.headers_file.read(read_count * header_len) |
||||
|
|
||||
|
count -= read_count |
||||
|
if count: |
||||
|
start = (height + read_count) - (self.height + 1) |
||||
|
result += b''.join(self.headers[start: start + count]) |
||||
|
|
||||
|
return result |
||||
|
|
||||
|
def get_tx_hash(self, tx_num): |
||||
|
'''Returns the tx_hash and height of a tx number.''' |
||||
|
height = bisect_right(self.tx_counts, tx_num) |
||||
|
|
||||
|
# Is this on disk or unflushed? |
||||
|
if height > self.height: |
||||
|
tx_hashes = self.tx_hashes[height - (self.height + 1)] |
||||
|
tx_hash = tx_hashes[tx_num - self.tx_counts[height - 1]] |
||||
|
else: |
||||
|
file_pos = tx_num * 32 |
||||
|
file_num, offset = divmod(file_pos, self.tx_hash_file_size) |
||||
|
filename = 'hashes{:04d}'.format(file_num) |
||||
|
with self.open_file(filename) as f: |
||||
|
f.seek(offset) |
||||
|
tx_hash = f.read(32) |
||||
|
|
||||
|
return tx_hash, height |
||||
|
|
||||
|
def block_hashes(self, height, count): |
||||
|
headers = self.read_headers(height, count) |
||||
|
hlen = self.coin.HEADER_LEN |
||||
|
return [double_sha256(header) for header in chunks(headers, hlen)] |
||||
|
|
||||
|
def encode_header(self, height): |
||||
|
if height < 0 or height > self.height + len(self.headers): |
||||
|
raise Exception('no header information for height {:,d}' |
||||
|
.format(height)) |
||||
|
header = self.read_headers(self.height, 1) |
||||
|
unpack = struct.unpack |
||||
|
version, = unpack('<I', header[:4]) |
||||
|
timestamp, bits, nonce = unpack('<III', header[68:80]) |
||||
|
|
||||
|
return { |
||||
|
'block_height': self.height, |
||||
|
'version': version, |
||||
|
'prev_block_hash': hash_to_str(header[4:36]), |
||||
|
'merkle_root': hash_to_str(header[36:68]), |
||||
|
'timestamp': timestamp, |
||||
|
'bits': bits, |
||||
|
'nonce': nonce, |
||||
|
} |
@ -0,0 +1,165 @@ |
|||||
|
# See the file "LICENSE" for information about the copyright |
||||
|
# and warranty status of this software. |
||||
|
|
||||
|
import asyncio |
||||
|
import signal |
||||
|
import traceback |
||||
|
from functools import partial |
||||
|
|
||||
|
from server.daemon import Daemon, DaemonError |
||||
|
from server.block_processor import BlockProcessor |
||||
|
from server.protocol import ElectrumX, LocalRPC |
||||
|
from lib.hash import (sha256, double_sha256, hash_to_str, |
||||
|
Base58, hex_str_to_hash) |
||||
|
from lib.util import LoggedClass |
||||
|
|
||||
|
|
||||
|
class Controller(LoggedClass): |
||||
|
|
||||
|
def __init__(self, loop, env): |
||||
|
'''Create up the controller. |
||||
|
|
||||
|
Creates DB, Daemon and BlockProcessor instances. |
||||
|
''' |
||||
|
super().__init__() |
||||
|
self.loop = loop |
||||
|
self.env = env |
||||
|
self.daemon = Daemon(env.daemon_url) |
||||
|
self.block_processor = BlockProcessor(env, self.daemon) |
||||
|
self.servers = [] |
||||
|
self.sessions = set() |
||||
|
self.addresses = {} |
||||
|
self.jobs = set() |
||||
|
self.peers = {} |
||||
|
|
||||
|
def start(self): |
||||
|
'''Prime the event loop with asynchronous servers and jobs.''' |
||||
|
env = self.env |
||||
|
loop = self.loop |
||||
|
|
||||
|
coros = self.block_processor.coros() |
||||
|
|
||||
|
if False: |
||||
|
self.start_servers() |
||||
|
coros.append(self.reap_jobs()) |
||||
|
|
||||
|
for coro in coros: |
||||
|
asyncio.ensure_future(coro) |
||||
|
|
||||
|
# Signal handlers |
||||
|
for signame in ('SIGINT', 'SIGTERM'): |
||||
|
loop.add_signal_handler(getattr(signal, signame), |
||||
|
partial(self.on_signal, signame)) |
||||
|
|
||||
|
def start_servers(self): |
||||
|
protocol = partial(LocalRPC, self) |
||||
|
if env.rpc_port is not None: |
||||
|
host = 'localhost' |
||||
|
rpc_server = loop.create_server(protocol, host, env.rpc_port) |
||||
|
self.servers.append(loop.run_until_complete(rpc_server)) |
||||
|
self.logger.info('RPC server listening on {}:{:d}' |
||||
|
.format(host, env.rpc_port)) |
||||
|
|
||||
|
protocol = partial(ElectrumX, self, self.daemon, env) |
||||
|
if env.tcp_port is not None: |
||||
|
tcp_server = loop.create_server(protocol, env.host, env.tcp_port) |
||||
|
self.servers.append(loop.run_until_complete(tcp_server)) |
||||
|
self.logger.info('TCP server listening on {}:{:d}' |
||||
|
.format(env.host, env.tcp_port)) |
||||
|
|
||||
|
if env.ssl_port is not None: |
||||
|
ssl_server = loop.create_server(protocol, env.host, env.ssl_port) |
||||
|
self.servers.append(loop.run_until_complete(ssl_server)) |
||||
|
self.logger.info('SSL server listening on {}:{:d}' |
||||
|
.format(env.host, env.ssl_port)) |
||||
|
|
||||
|
def stop(self): |
||||
|
'''Close the listening servers.''' |
||||
|
for server in self.servers: |
||||
|
server.close() |
||||
|
|
||||
|
def on_signal(self, signame): |
||||
|
'''Call on receipt of a signal to cleanly shutdown.''' |
||||
|
self.logger.warning('received {} signal, preparing to shut down' |
||||
|
.format(signame)) |
||||
|
for task in asyncio.Task.all_tasks(self.loop): |
||||
|
task.cancel() |
||||
|
|
||||
|
def add_session(self, session): |
||||
|
self.sessions.add(session) |
||||
|
|
||||
|
def remove_session(self, session): |
||||
|
self.sessions.remove(session) |
||||
|
|
||||
|
def add_job(self, coro): |
||||
|
'''Queue a job for asynchronous processing.''' |
||||
|
self.jobs.add(asyncio.ensure_future(coro)) |
||||
|
|
||||
|
async def reap_jobs(self): |
||||
|
while True: |
||||
|
jobs = set() |
||||
|
for job in self.jobs: |
||||
|
if job.done(): |
||||
|
try: |
||||
|
job.result() |
||||
|
except Exception as e: |
||||
|
traceback.print_exc() |
||||
|
else: |
||||
|
jobs.add(job) |
||||
|
self.logger.info('reaped {:d} jobs, {:d} jobs pending' |
||||
|
.format(len(self.jobs) - len(jobs), len(jobs))) |
||||
|
self.jobs = jobs |
||||
|
await asyncio.sleep(5) |
||||
|
|
||||
|
def address_status(self, hash168): |
||||
|
'''Returns status as 32 bytes.''' |
||||
|
status = self.addresses.get(hash168) |
||||
|
if status is None: |
||||
|
history = self.block_processor.get_history(hash168) |
||||
|
status = ''.join('{}:{:d}:'.format(hash_to_str(tx_hash), height) |
||||
|
for tx_hash, height in history) |
||||
|
if status: |
||||
|
status = sha256(status.encode()) |
||||
|
self.addresses[hash168] = status |
||||
|
|
||||
|
return status |
||||
|
|
||||
|
async def get_merkle(self, tx_hash, height): |
||||
|
'''tx_hash is a hex string.''' |
||||
|
block_hash = await self.daemon.send_single('getblockhash', (height,)) |
||||
|
block = await self.daemon.send_single('getblock', (block_hash, True)) |
||||
|
tx_hashes = block['tx'] |
||||
|
# This will throw if the tx_hash is bad |
||||
|
pos = tx_hashes.index(tx_hash) |
||||
|
|
||||
|
idx = pos |
||||
|
hashes = [hex_str_to_hash(txh) for txh in tx_hashes] |
||||
|
merkle_branch = [] |
||||
|
while len(hashes) > 1: |
||||
|
if len(hashes) & 1: |
||||
|
hashes.append(hashes[-1]) |
||||
|
idx = idx - 1 if (idx & 1) else idx + 1 |
||||
|
merkle_branch.append(hash_to_str(hashes[idx])) |
||||
|
idx //= 2 |
||||
|
hashes = [double_sha256(hashes[n] + hashes[n + 1]) |
||||
|
for n in range(0, len(hashes), 2)] |
||||
|
|
||||
|
return {"block_height": height, "merkle": merkle_branch, "pos": pos} |
||||
|
|
||||
|
def get_peers(self): |
||||
|
'''Returns a dictionary of IRC nick to (ip, host, ports) tuples, one |
||||
|
per peer.''' |
||||
|
return self.peers |
||||
|
|
||||
|
def height(self): |
||||
|
return self.block_processor.height |
||||
|
|
||||
|
def get_current_header(self): |
||||
|
return self.block_processor.get_current_header() |
||||
|
|
||||
|
def get_history(self, hash168): |
||||
|
history = self.block_processor.get_history(hash168, limit=None) |
||||
|
return [ |
||||
|
{'tx_hash': hash_to_str(tx_hash), 'height': height} |
||||
|
for tx_hash, height in history |
||||
|
] |
@ -0,0 +1,98 @@ |
|||||
|
# See the file "LICENSE" for information about the copyright |
||||
|
# and warranty status of this software. |
||||
|
|
||||
|
'''Classes for handling asynchronous connections to a blockchain |
||||
|
daemon.''' |
||||
|
|
||||
|
import asyncio |
||||
|
import json |
||||
|
|
||||
|
import aiohttp |
||||
|
|
||||
|
from lib.util import LoggedClass |
||||
|
|
||||
|
|
||||
|
class DaemonError(Exception): |
||||
|
'''Raised when the daemon returns an error in its results that |
||||
|
cannot be remedied by retrying.''' |
||||
|
|
||||
|
|
||||
|
class Daemon(LoggedClass): |
||||
|
'''Handles connections to a daemon at the given URL.''' |
||||
|
|
||||
|
def __init__(self, url): |
||||
|
super().__init__() |
||||
|
self.url = url |
||||
|
self._height = None |
||||
|
self.logger.info('connecting to daemon at URL {}'.format(url)) |
||||
|
|
||||
|
async def send_single(self, method, params=None): |
||||
|
payload = {'method': method} |
||||
|
if params: |
||||
|
payload['params'] = params |
||||
|
result, = await self.send((payload, )) |
||||
|
return result |
||||
|
|
||||
|
async def send_many(self, mp_pairs): |
||||
|
if mp_pairs: |
||||
|
payload = [{'method': method, 'params': params} |
||||
|
for method, params in mp_pairs] |
||||
|
return await self.send(payload) |
||||
|
return [] |
||||
|
|
||||
|
async def send_vector(self, method, params_list): |
||||
|
if params_list: |
||||
|
payload = [{'method': method, 'params': params} |
||||
|
for params in params_list] |
||||
|
return await self.send(payload) |
||||
|
return [] |
||||
|
|
||||
|
async def send(self, payload): |
||||
|
assert isinstance(payload, (tuple, list)) |
||||
|
data = json.dumps(payload) |
||||
|
while True: |
||||
|
try: |
||||
|
async with aiohttp.post(self.url, data=data) as resp: |
||||
|
result = await resp.json() |
||||
|
except asyncio.CancelledError: |
||||
|
raise |
||||
|
except Exception as e: |
||||
|
msg = 'aiohttp error: {}'.format(e) |
||||
|
secs = 3 |
||||
|
else: |
||||
|
errs = tuple(item['error'] for item in result) |
||||
|
if not any(errs): |
||||
|
return tuple(item['result'] for item in result) |
||||
|
if any(err.get('code') == -28 for err in errs): |
||||
|
msg = 'daemon still warming up.' |
||||
|
secs = 30 |
||||
|
else: |
||||
|
msg = '{}'.format(errs) |
||||
|
raise DaemonError(msg) |
||||
|
|
||||
|
self.logger.error('{}. Sleeping {:d}s and trying again...' |
||||
|
.format(msg, secs)) |
||||
|
await asyncio.sleep(secs) |
||||
|
|
||||
|
async def block_hex_hashes(self, first, count): |
||||
|
'''Return the hex hashes of count block starting at height first.''' |
||||
|
param_lists = [[height] for height in range(first, first + count)] |
||||
|
return await self.send_vector('getblockhash', param_lists) |
||||
|
|
||||
|
async def raw_blocks(self, hex_hashes): |
||||
|
'''Return the raw binary blocks with the given hex hashes.''' |
||||
|
param_lists = [(h, False) for h in hex_hashes] |
||||
|
blocks = await self.send_vector('getblock', param_lists) |
||||
|
# Convert hex string to bytes |
||||
|
return [bytes.fromhex(block) for block in blocks] |
||||
|
|
||||
|
async def height(self): |
||||
|
'''Query the daemon for its current height.''' |
||||
|
self._height = await self.send_single('getblockcount') |
||||
|
return self._height |
||||
|
|
||||
|
def cached_height(self): |
||||
|
'''Return the cached daemon height. |
||||
|
|
||||
|
If the daemon has not been queried yet this returns None.''' |
||||
|
return self._height |
@ -1,679 +0,0 @@ |
|||||
# See the file "LICENSE" for information about the copyright |
|
||||
# and warranty status of this software. |
|
||||
|
|
||||
import array |
|
||||
import ast |
|
||||
import itertools |
|
||||
import os |
|
||||
import struct |
|
||||
import time |
|
||||
from binascii import hexlify, unhexlify |
|
||||
from bisect import bisect_right |
|
||||
from collections import defaultdict, namedtuple |
|
||||
from functools import partial |
|
||||
import logging |
|
||||
|
|
||||
import plyvel |
|
||||
|
|
||||
from lib.coins import Bitcoin |
|
||||
from lib.script import ScriptPubKey |
|
||||
|
|
||||
# History can hold approx. 65536 * HIST_ENTRIES_PER_KEY entries |
|
||||
HIST_ENTRIES_PER_KEY = 1024 |
|
||||
HIST_VALUE_BYTES = HIST_ENTRIES_PER_KEY * 4 |
|
||||
ADDR_TX_HASH_LEN = 4 |
|
||||
UTXO_TX_HASH_LEN = 4 |
|
||||
UTXO = namedtuple("UTXO", "tx_num tx_pos tx_hash height value") |
|
||||
|
|
||||
|
|
||||
def formatted_time(t): |
|
||||
t = int(t) |
|
||||
return '{:d}d {:02d}h {:02d}m {:02d}s'.format( |
|
||||
t // 86400, (t % 86400) // 3600, (t % 3600) // 60, t % 60) |
|
||||
|
|
||||
|
|
||||
class UTXOCache(object): |
|
||||
'''An in-memory UTXO cache, representing all changes to UTXO state |
|
||||
since the last DB flush. |
|
||||
|
|
||||
We want to store millions, perhaps 10s of millions of these in |
|
||||
memory for optimal performance during initial sync, because then |
|
||||
it is possible to spend UTXOs without ever going to the database |
|
||||
(other than as an entry in the address history, and there is only |
|
||||
one such entry per TX not per UTXO). So store them in a Python |
|
||||
dictionary with binary keys and values. |
|
||||
|
|
||||
Key: TX_HASH + TX_IDX (32 + 2 = 34 bytes) |
|
||||
Value: HASH168 + TX_NUM + VALUE (21 + 4 + 8 = 33 bytes) |
|
||||
|
|
||||
That's 67 bytes of raw data. Python dictionary overhead means |
|
||||
each entry actually uses about 187 bytes of memory. So almost |
|
||||
11.5 million UTXOs can fit in 2GB of RAM. There are approximately |
|
||||
42 million UTXOs on bitcoin mainnet at height 433,000. |
|
||||
|
|
||||
Semantics: |
|
||||
|
|
||||
add: Add it to the cache dictionary. |
|
||||
spend: Remove it if in the cache dictionary. |
|
||||
Otherwise it's been flushed to the DB. Each UTXO |
|
||||
is responsible for two entries in the DB stored using |
|
||||
compressed keys. Mark both for deletion in the next |
|
||||
flush of the in-memory UTXO cache. |
|
||||
|
|
||||
A UTXO is stored in the DB in 2 "tables": |
|
||||
|
|
||||
1. The output value and tx number. Must be keyed with a |
|
||||
hash168 prefix so the unspent outputs and balance of an |
|
||||
arbitrary address can be looked up with a simple key |
|
||||
traversal. |
|
||||
Key: b'u' + hash168 + compressed_tx_hash + tx_idx |
|
||||
Value: a (tx_num, value) pair |
|
||||
|
|
||||
2. Given a prevout, we need to be able to look up the UTXO key |
|
||||
to remove it. As is keyed by hash168 and that is not part |
|
||||
of the prevout, we need a hash168 lookup. |
|
||||
Key: b'h' + compressed tx_hash + tx_idx |
|
||||
Value: (hash168, tx_num) pair |
|
||||
|
|
||||
The compressed TX hash is just the first few bytes of the hash of |
|
||||
the TX the UTXO is in (and needn't be the same number of bytes in |
|
||||
each table). As this is not unique there will be collisions; |
|
||||
tx_num is stored to resolve them. The collision rate is around |
|
||||
0.02% for the hash168 table, and almost zero for the UTXO table |
|
||||
(there are around 100 collisions in the whole bitcoin blockchain). |
|
||||
|
|
||||
''' |
|
||||
|
|
||||
def __init__(self, parent, db, coin): |
|
||||
self.logger = logging.getLogger('UTXO') |
|
||||
self.logger.setLevel(logging.INFO) |
|
||||
self.parent = parent |
|
||||
self.coin = coin |
|
||||
self.cache = {} |
|
||||
self.db = db |
|
||||
self.db_cache = {} |
|
||||
# Statistics |
|
||||
self.adds = 0 |
|
||||
self.cache_hits = 0 |
|
||||
self.db_deletes = 0 |
|
||||
|
|
||||
def add_many(self, tx_hash, tx_num, txouts): |
|
||||
'''Add a sequence of UTXOs to the cache, return the set of hash168s |
|
||||
seen. |
|
||||
|
|
||||
Pass the hash of the TX it appears in, its TX number, and the |
|
||||
TX outputs. |
|
||||
''' |
|
||||
parse_script = ScriptPubKey.from_script |
|
||||
pack = struct.pack |
|
||||
tx_numb = pack('<I', tx_num) |
|
||||
hash168s = set() |
|
||||
|
|
||||
self.adds += len(txouts) |
|
||||
for idx, txout in enumerate(txouts): |
|
||||
# Get the hash168. Ignore scripts we can't grok. |
|
||||
pk = parse_script(txout.pk_script, self.coin) |
|
||||
hash168 = pk.hash168 |
|
||||
if not hash168: |
|
||||
continue |
|
||||
|
|
||||
hash168s.add(hash168) |
|
||||
key = tx_hash + pack('<H', idx) |
|
||||
|
|
||||
# Well-known duplicate coinbases from heights 91722-91880 |
|
||||
# that destoyed 100 BTC forever: |
|
||||
# e3bf3d07d4b0375638d5f1db5255fe07ba2c4cb067cd81b84ee974b6585fb468 |
|
||||
# d5d27987d2a3dfc724e359870c6644b40e497bdc0589a033220fe15429d88599 |
|
||||
#if key in self.cache: |
|
||||
# self.logger.info('duplicate tx hash {}' |
|
||||
# .format(bytes(reversed(tx_hash)).hex())) |
|
||||
|
|
||||
# b''.join avoids this: https://bugs.python.org/issue13298 |
|
||||
self.cache[key] = b''.join( |
|
||||
(hash168, tx_numb, pack('<Q', txout.value))) |
|
||||
|
|
||||
return hash168s |
|
||||
|
|
||||
def spend(self, prevout): |
|
||||
'''Spend a UTXO and return the address spent. |
|
||||
|
|
||||
If the UTXO is not in the cache it must be on disk. |
|
||||
''' |
|
||||
# Fast track is it's in the cache |
|
||||
pack = struct.pack |
|
||||
key = b''.join((prevout.hash, pack('<H', prevout.n))) |
|
||||
value = self.cache.pop(key, None) |
|
||||
if value: |
|
||||
self.cache_hits += 1 |
|
||||
return value[:21] |
|
||||
|
|
||||
# Oh well. Find and remove it from the DB. |
|
||||
hash168 = self.hash168(prevout.hash, prevout.n) |
|
||||
if not hash168: |
|
||||
return None |
|
||||
|
|
||||
self.db_deletes += 1 |
|
||||
|
|
||||
# Read the UTXO through the cache from the disk. We have to |
|
||||
# go through the cache because compressed keys can collide. |
|
||||
key = (b'u' + hash168 + prevout.hash[:UTXO_TX_HASH_LEN] |
|
||||
+ pack('<H', prevout.n)) |
|
||||
data = self.cache_get(key) |
|
||||
if data is None: |
|
||||
# Uh-oh, this should not happen... |
|
||||
self.logger.error('found no UTXO for {} / {:d} key {}' |
|
||||
.format(bytes(reversed(prevout.hash)).hex(), |
|
||||
prevout.n, bytes(key).hex())) |
|
||||
return hash168 |
|
||||
|
|
||||
if len(data) == 12: |
|
||||
(tx_num, ) = struct.unpack('<I', data[:4]) |
|
||||
self.cache_delete(key) |
|
||||
return hash168 |
|
||||
|
|
||||
# Resolve the compressed key collison. These should be |
|
||||
# extremely rare. |
|
||||
assert len(data) % 12 == 0 |
|
||||
for n in range(0, len(data), 12): |
|
||||
(tx_num, ) = struct.unpack('<I', data[n:n+4]) |
|
||||
tx_hash, height = self.parent.get_tx_hash(tx_num) |
|
||||
if prevout.hash == tx_hash: |
|
||||
data = data[:n] + data[n + 12:] |
|
||||
self.cache_write(key, data) |
|
||||
return hash168 |
|
||||
|
|
||||
raise Exception('could not resolve UTXO key collision') |
|
||||
|
|
||||
def hash168(self, tx_hash, idx): |
|
||||
'''Return the hash168 paid to by the given TXO. |
|
||||
|
|
||||
Refers to the database. Returns None if not found (which is |
|
||||
indicates a non-standard script). |
|
||||
''' |
|
||||
key = b'h' + tx_hash[:ADDR_TX_HASH_LEN] + struct.pack('<H', idx) |
|
||||
data = self.cache_get(key) |
|
||||
if data is None: |
|
||||
# Assuming the DB is not corrupt, this indicates a |
|
||||
# successful spend of a non-standard script |
|
||||
# self.logger.info('ignoring spend of non-standard UTXO {} / {:d}' |
|
||||
# .format(bytes(reversed(tx_hash)).hex(), idx))) |
|
||||
return None |
|
||||
|
|
||||
if len(data) == 25: |
|
||||
self.cache_delete(key) |
|
||||
return data[:21] |
|
||||
|
|
||||
assert len(data) % 25 == 0 |
|
||||
|
|
||||
# Resolve the compressed key collision using the TX number |
|
||||
for n in range(0, len(data), 25): |
|
||||
(tx_num, ) = struct.unpack('<I', data[n+21:n+25]) |
|
||||
my_hash, height = self.parent.get_tx_hash(tx_num) |
|
||||
if my_hash == tx_hash: |
|
||||
self.cache_write(key, data[:n] + data[n+25:]) |
|
||||
return data[n:n+21] |
|
||||
|
|
||||
raise Exception('could not resolve hash168 collision') |
|
||||
|
|
||||
def cache_write(self, key, value): |
|
||||
'''Cache write of a (key, value) pair to the DB.''' |
|
||||
assert(bool(value)) |
|
||||
self.db_cache[key] = value |
|
||||
|
|
||||
def cache_delete(self, key): |
|
||||
'''Cache deletion of a key from the DB.''' |
|
||||
self.db_cache[key] = None |
|
||||
|
|
||||
def cache_get(self, key): |
|
||||
'''Fetch a value from the DB through our write cache.''' |
|
||||
value = self.db_cache.get(key) |
|
||||
if value: |
|
||||
return value |
|
||||
return self.db.get(key) |
|
||||
|
|
||||
def flush(self, batch): |
|
||||
'''Flush the cached DB writes and UTXO set to the batch.''' |
|
||||
# Care is needed because the writes generated by flushing the |
|
||||
# UTXO state may have keys in common with our write cache or |
|
||||
# may be in the DB already. |
|
||||
hcolls = ucolls = 0 |
|
||||
new_utxos = len(self.cache) |
|
||||
for cache_key, cache_value in self.cache.items(): |
|
||||
# Frist write to the hash168 lookup table |
|
||||
key = b'h' + cache_key[:ADDR_TX_HASH_LEN] + cache_key[-2:] |
|
||||
value = cache_value[:25] |
|
||||
prior_value = self.cache_get(key) |
|
||||
if prior_value: # Should rarely happen |
|
||||
hcolls += 1 |
|
||||
value += prior_value |
|
||||
self.cache_write(key, value) |
|
||||
|
|
||||
# Next write the UTXO table |
|
||||
key = (b'u' + cache_value[:21] + cache_key[:UTXO_TX_HASH_LEN] |
|
||||
+ cache_key[-2:]) |
|
||||
value = cache_value[-12:] |
|
||||
prior_value = self.cache_get(key) |
|
||||
if prior_value: # Should almost never happen |
|
||||
ucolls += 1 |
|
||||
value += prior_value |
|
||||
self.cache_write(key, value) |
|
||||
|
|
||||
# GC-ing this now can only help the levelDB write. |
|
||||
self.cache = {} |
|
||||
|
|
||||
# Now we can update to the batch. |
|
||||
for key, value in self.db_cache.items(): |
|
||||
if value: |
|
||||
batch.put(key, value) |
|
||||
else: |
|
||||
batch.delete(key) |
|
||||
|
|
||||
self.db_cache = {} |
|
||||
|
|
||||
self.logger.info('UTXO cache adds: {:,d} spends: {:,d} ' |
|
||||
.format(self.adds, self.cache_hits)) |
|
||||
self.logger.info('UTXO DB adds: {:,d} spends: {:,d}. ' |
|
||||
'Collisions: hash168: {:,d} UTXO: {:,d}' |
|
||||
.format(new_utxos, self.db_deletes, |
|
||||
hcolls, ucolls)) |
|
||||
self.adds = self.cache_hits = self.db_deletes = 0 |
|
||||
|
|
||||
|
|
||||
class DB(object): |
|
||||
|
|
||||
class Error(Exception): |
|
||||
pass |
|
||||
|
|
||||
def __init__(self, env): |
|
||||
self.logger = logging.getLogger('DB') |
|
||||
self.logger.setLevel(logging.INFO) |
|
||||
|
|
||||
# Meta |
|
||||
self.tx_hash_file_size = 16 * 1024 * 1024 |
|
||||
self.utxo_MB = env.utxo_MB |
|
||||
self.hist_MB = env.hist_MB |
|
||||
self.next_cache_check = 0 |
|
||||
self.last_flush = time.time() |
|
||||
self.coin = env.coin |
|
||||
|
|
||||
# Chain state (initialize to genesis in case of new DB) |
|
||||
self.db_height = -1 |
|
||||
self.db_tx_count = 0 |
|
||||
self.flush_count = 0 |
|
||||
self.utxo_flush_count = 0 |
|
||||
self.wall_time = 0 |
|
||||
self.tip = self.coin.GENESIS_HASH |
|
||||
|
|
||||
# Open DB and metadata files. Record some of its state. |
|
||||
self.db = self.open_db(self.coin) |
|
||||
self.tx_count = self.fs_tx_count = self.db_tx_count |
|
||||
self.height = self.fs_height = self.db_height |
|
||||
|
|
||||
# Caches to be flushed later. Headers and tx_hashes have one |
|
||||
# entry per block |
|
||||
self.headers = [] |
|
||||
self.tx_hashes = [] |
|
||||
self.history = defaultdict(partial(array.array, 'I')) |
|
||||
self.history_size = 0 |
|
||||
self.utxo_cache = UTXOCache(self, self.db, self.coin) |
|
||||
self.tx_counts = array.array('I') |
|
||||
self.txcount_file.seek(0) |
|
||||
self.tx_counts.fromfile(self.txcount_file, self.height + 1) |
|
||||
|
|
||||
# Log state |
|
||||
self.logger.info('{}/{} height: {:,d} tx count: {:,d} ' |
|
||||
'flush count: {:,d} utxo flush count: {:,d} ' |
|
||||
'sync time: {}' |
|
||||
.format(self.coin.NAME, self.coin.NET, self.height, |
|
||||
self.tx_count, self.flush_count, |
|
||||
self.utxo_flush_count, |
|
||||
formatted_time(self.wall_time))) |
|
||||
self.logger.info('flushing UTXO cache at {:,d} MB' |
|
||||
.format(self.utxo_MB)) |
|
||||
self.logger.info('flushing history cache at {:,d} MB' |
|
||||
.format(self.hist_MB)) |
|
||||
|
|
||||
|
|
||||
def open_db(self, coin): |
|
||||
db_name = '{}-{}'.format(coin.NAME, coin.NET) |
|
||||
is_new = False |
|
||||
try: |
|
||||
db = plyvel.DB(db_name, create_if_missing=False, |
|
||||
error_if_exists=False, compression=None) |
|
||||
except: |
|
||||
db = plyvel.DB(db_name, create_if_missing=True, |
|
||||
error_if_exists=True, compression=None) |
|
||||
is_new = True |
|
||||
|
|
||||
if is_new: |
|
||||
self.logger.info('created new database {}'.format(db_name)) |
|
||||
self.flush_state(db) |
|
||||
else: |
|
||||
self.logger.info('successfully opened database {}'.format(db_name)) |
|
||||
self.read_state(db) |
|
||||
self.delete_excess_history(db) |
|
||||
|
|
||||
self.headers_file = self.open_file('headers', is_new) |
|
||||
self.txcount_file = self.open_file('txcount', is_new) |
|
||||
|
|
||||
return db |
|
||||
|
|
||||
def read_state(self, db): |
|
||||
state = db.get(b'state') |
|
||||
state = ast.literal_eval(state.decode('ascii')) |
|
||||
if state['genesis'] != self.coin.GENESIS_HASH: |
|
||||
raise self.Error('DB genesis hash {} does not match coin {}' |
|
||||
.format(state['genesis_hash'], |
|
||||
self.coin.GENESIS_HASH)) |
|
||||
self.db_height = state['height'] |
|
||||
self.db_tx_count = state['tx_count'] |
|
||||
self.tip = state['tip'] |
|
||||
self.flush_count = state['flush_count'] |
|
||||
self.utxo_flush_count = state['utxo_flush_count'] |
|
||||
self.wall_time = state['wall_time'] |
|
||||
|
|
||||
def delete_excess_history(self, db): |
|
||||
'''Clear history flushed since the most recent UTXO flush.''' |
|
||||
utxo_flush_count = self.utxo_flush_count |
|
||||
diff = self.flush_count - utxo_flush_count |
|
||||
if diff == 0: |
|
||||
return |
|
||||
if diff < 0: |
|
||||
raise self.Error('DB corrupt: flush_count < utxo_flush_count') |
|
||||
|
|
||||
self.logger.info('DB not shut down cleanly. Scanning for most ' |
|
||||
'recent {:,d} history flushes'.format(diff)) |
|
||||
prefix = b'H' |
|
||||
unpack = struct.unpack |
|
||||
keys = [] |
|
||||
for key, hist in db.iterator(prefix=prefix): |
|
||||
flush_id, = unpack('>H', key[-2:]) |
|
||||
if flush_id > self.utxo_flush_count: |
|
||||
keys.append(key) |
|
||||
|
|
||||
self.logger.info('deleting {:,d} history entries'.format(len(keys))) |
|
||||
with db.write_batch(transaction=True) as batch: |
|
||||
for key in keys: |
|
||||
db.delete(key) |
|
||||
self.utxo_flush_count = self.flush_count |
|
||||
self.flush_state(batch) |
|
||||
self.logger.info('deletion complete') |
|
||||
|
|
||||
def flush_to_fs(self): |
|
||||
'''Flush the things stored on the filesystem.''' |
|
||||
# First the headers |
|
||||
headers = b''.join(self.headers) |
|
||||
header_len = self.coin.HEADER_LEN |
|
||||
self.headers_file.seek((self.fs_height + 1) * header_len) |
|
||||
self.headers_file.write(headers) |
|
||||
self.headers_file.flush() |
|
||||
self.headers = [] |
|
||||
|
|
||||
# Then the tx counts |
|
||||
self.txcount_file.seek((self.fs_height + 1) * self.tx_counts.itemsize) |
|
||||
self.txcount_file.write(self.tx_counts[self.fs_height + 1: |
|
||||
self.height + 1]) |
|
||||
self.txcount_file.flush() |
|
||||
|
|
||||
# Finally the hashes |
|
||||
hashes = memoryview(b''.join(itertools.chain(*self.tx_hashes))) |
|
||||
assert len(hashes) % 32 == 0 |
|
||||
assert self.tx_hash_file_size % 32 == 0 |
|
||||
cursor = 0 |
|
||||
file_pos = self.fs_tx_count * 32 |
|
||||
while cursor < len(hashes): |
|
||||
file_num, offset = divmod(file_pos, self.tx_hash_file_size) |
|
||||
size = min(len(hashes) - cursor, self.tx_hash_file_size - offset) |
|
||||
filename = 'hashes{:04d}'.format(file_num) |
|
||||
with self.open_file(filename, create=True) as f: |
|
||||
f.seek(offset) |
|
||||
f.write(hashes[cursor:cursor + size]) |
|
||||
cursor += size |
|
||||
file_pos += size |
|
||||
self.tx_hashes = [] |
|
||||
|
|
||||
self.fs_height = self.height |
|
||||
self.fs_tx_count = self.tx_count |
|
||||
os.sync() |
|
||||
|
|
||||
def flush_state(self, batch): |
|
||||
'''Flush chain state to the batch.''' |
|
||||
now = time.time() |
|
||||
self.wall_time += now - self.last_flush |
|
||||
self.last_flush = now |
|
||||
state = { |
|
||||
'genesis': self.coin.GENESIS_HASH, |
|
||||
'height': self.db_height, |
|
||||
'tx_count': self.db_tx_count, |
|
||||
'tip': self.tip, |
|
||||
'flush_count': self.flush_count, |
|
||||
'utxo_flush_count': self.utxo_flush_count, |
|
||||
'wall_time': self.wall_time, |
|
||||
} |
|
||||
batch.put(b'state', repr(state).encode('ascii')) |
|
||||
|
|
||||
def flush_utxos(self, batch): |
|
||||
self.logger.info('flushing UTXOs: {:,d} txs and {:,d} blocks' |
|
||||
.format(self.tx_count - self.db_tx_count, |
|
||||
self.height - self.db_height)) |
|
||||
self.utxo_cache.flush(batch) |
|
||||
self.utxo_flush_count = self.flush_count |
|
||||
self.db_tx_count = self.tx_count |
|
||||
self.db_height = self.height |
|
||||
|
|
||||
def flush(self, daemon_height, flush_utxos=False): |
|
||||
'''Flush out cached state. |
|
||||
|
|
||||
History is always flushed. UTXOs are flushed if flush_utxos.''' |
|
||||
flush_start = time.time() |
|
||||
last_flush = self.last_flush |
|
||||
tx_diff = self.tx_count - self.fs_tx_count |
|
||||
|
|
||||
# Write out the files to the FS before flushing to the DB. If |
|
||||
# the DB transaction fails, the files being too long doesn't |
|
||||
# matter. But if writing the files fails we do not want to |
|
||||
# have updated the DB. |
|
||||
self.logger.info('commencing history flush') |
|
||||
self.flush_to_fs() |
|
||||
|
|
||||
with self.db.write_batch(transaction=True) as batch: |
|
||||
# History first - fast and frees memory. Flush state last |
|
||||
# as it reads the wall time. |
|
||||
self.flush_history(batch) |
|
||||
if flush_utxos: |
|
||||
self.flush_utxos(batch) |
|
||||
self.flush_state(batch) |
|
||||
self.logger.info('committing transaction...') |
|
||||
|
|
||||
# Update and put the wall time again - otherwise we drop the |
|
||||
# time it took leveldb to commit the batch |
|
||||
self.flush_state(self.db) |
|
||||
|
|
||||
flush_time = int(self.last_flush - flush_start) |
|
||||
self.logger.info('flush #{:,d} to height {:,d} took {:,d}s' |
|
||||
.format(self.flush_count, self.height, flush_time)) |
|
||||
|
|
||||
# Log handy stats |
|
||||
txs_per_sec = int(self.tx_count / self.wall_time) |
|
||||
this_txs_per_sec = 1 + int(tx_diff / (self.last_flush - last_flush)) |
|
||||
if self.height > self.coin.TX_COUNT_HEIGHT: |
|
||||
tx_est = (daemon_height - self.height) * self.coin.TX_PER_BLOCK |
|
||||
else: |
|
||||
tx_est = ((daemon_height - self.coin.TX_COUNT_HEIGHT) |
|
||||
* self.coin.TX_PER_BLOCK |
|
||||
+ (self.coin.TX_COUNT - self.tx_count)) |
|
||||
|
|
||||
self.logger.info('txs: {:,d} tx/sec since genesis: {:,d}, ' |
|
||||
'since last flush: {:,d}' |
|
||||
.format(self.tx_count, txs_per_sec, this_txs_per_sec)) |
|
||||
self.logger.info('sync time: {} ETA: {}' |
|
||||
.format(formatted_time(self.wall_time), |
|
||||
formatted_time(tx_est / this_txs_per_sec))) |
|
||||
|
|
||||
def flush_history(self, batch): |
|
||||
# Drop any None entry |
|
||||
self.history.pop(None, None) |
|
||||
|
|
||||
self.flush_count += 1 |
|
||||
flush_id = struct.pack('>H', self.flush_count) |
|
||||
for hash168, hist in self.history.items(): |
|
||||
key = b'H' + hash168 + flush_id |
|
||||
batch.put(key, hist.tobytes()) |
|
||||
|
|
||||
self.logger.info('{:,d} history entries in {:,d} addrs' |
|
||||
.format(self.history_size, len(self.history))) |
|
||||
|
|
||||
self.history = defaultdict(partial(array.array, 'I')) |
|
||||
self.history_size = 0 |
|
||||
|
|
||||
def open_file(self, filename, create=False): |
|
||||
'''Open the file name. Return its handle.''' |
|
||||
try: |
|
||||
return open(filename, 'rb+') |
|
||||
except FileNotFoundError: |
|
||||
if create: |
|
||||
return open(filename, 'wb+') |
|
||||
raise |
|
||||
|
|
||||
def read_headers(self, height, count): |
|
||||
header_len = self.coin.HEADER_LEN |
|
||||
self.headers_file.seek(height * header_len) |
|
||||
return self.headers_file.read(count * header_len) |
|
||||
|
|
||||
def cache_sizes(self, daemon_height): |
|
||||
'''Returns the approximate size of the cache, in MB.''' |
|
||||
# Good average estimates based on traversal of subobjects and |
|
||||
# requesting size from Python (see deep_getsizeof). For |
|
||||
# whatever reason Python O/S mem usage is typically +30% or |
|
||||
# more, so we scale our already bloated object sizes. |
|
||||
one_MB = int(1048576 / 1.3) |
|
||||
utxo_cache_size = len(self.utxo_cache.cache) * 187 |
|
||||
db_cache_size = len(self.utxo_cache.db_cache) * 105 |
|
||||
hist_cache_size = len(self.history) * 180 + self.history_size * 4 |
|
||||
utxo_MB = (db_cache_size + utxo_cache_size) // one_MB |
|
||||
hist_MB = hist_cache_size // one_MB |
|
||||
|
|
||||
self.logger.info('cache stats at height {:,d} daemon height: {:,d}' |
|
||||
.format(self.height, daemon_height)) |
|
||||
self.logger.info(' entries: UTXO: {:,d} DB: {:,d} ' |
|
||||
'hist addrs: {:,d} hist size: {:,d}' |
|
||||
.format(len(self.utxo_cache.cache), |
|
||||
len(self.utxo_cache.db_cache), |
|
||||
len(self.history), |
|
||||
self.history_size)) |
|
||||
self.logger.info(' size: {:,d}MB (UTXOs {:,d}MB hist {:,d}MB)' |
|
||||
.format(utxo_MB + hist_MB, utxo_MB, hist_MB)) |
|
||||
return utxo_MB, hist_MB |
|
||||
|
|
||||
def process_block(self, block, daemon_height): |
|
||||
self.headers.append(block[:self.coin.HEADER_LEN]) |
|
||||
|
|
||||
tx_hashes, txs = self.coin.read_block(block) |
|
||||
self.height += 1 |
|
||||
|
|
||||
assert len(self.tx_counts) == self.height |
|
||||
|
|
||||
# These both need to be updated before calling process_tx(). |
|
||||
# It uses them for tx hash lookup |
|
||||
self.tx_hashes.append(tx_hashes) |
|
||||
self.tx_counts.append(self.tx_count + len(txs)) |
|
||||
|
|
||||
for tx_hash, tx in zip(tx_hashes, txs): |
|
||||
self.process_tx(tx_hash, tx) |
|
||||
|
|
||||
# Check if we're getting full and time to flush? |
|
||||
now = time.time() |
|
||||
if now > self.next_cache_check: |
|
||||
self.next_cache_check = now + 60 |
|
||||
utxo_MB, hist_MB = self.cache_sizes(daemon_height) |
|
||||
if utxo_MB >= self.utxo_MB or hist_MB >= self.hist_MB: |
|
||||
self.flush(daemon_height, utxo_MB >= self.utxo_MB) |
|
||||
|
|
||||
def process_tx(self, tx_hash, tx): |
|
||||
cache = self.utxo_cache |
|
||||
tx_num = self.tx_count |
|
||||
|
|
||||
# Add the outputs as new UTXOs; spend the inputs |
|
||||
hash168s = cache.add_many(tx_hash, tx_num, tx.outputs) |
|
||||
if not tx.is_coinbase: |
|
||||
for txin in tx.inputs: |
|
||||
hash168s.add(cache.spend(txin.prevout)) |
|
||||
|
|
||||
for hash168 in hash168s: |
|
||||
self.history[hash168].append(tx_num) |
|
||||
self.history_size += len(hash168s) |
|
||||
|
|
||||
self.tx_count += 1 |
|
||||
|
|
||||
def get_tx_hash(self, tx_num): |
|
||||
'''Returns the tx_hash and height of a tx number.''' |
|
||||
height = bisect_right(self.tx_counts, tx_num) |
|
||||
|
|
||||
# Is this on disk or unflushed? |
|
||||
if height > self.fs_height: |
|
||||
tx_hashes = self.tx_hashes[height - (self.fs_height + 1)] |
|
||||
tx_hash = tx_hashes[tx_num - self.tx_counts[height - 1]] |
|
||||
else: |
|
||||
file_pos = tx_num * 32 |
|
||||
file_num, offset = divmod(file_pos, self.tx_hash_file_size) |
|
||||
filename = 'hashes{:04d}'.format(file_num) |
|
||||
with self.open_file(filename) as f: |
|
||||
f.seek(offset) |
|
||||
tx_hash = f.read(32) |
|
||||
|
|
||||
return tx_hash, height |
|
||||
|
|
||||
@staticmethod |
|
||||
def resolve_limit(limit): |
|
||||
if limit is None: |
|
||||
return -1 |
|
||||
assert isinstance(limit, int) and limit >= 0 |
|
||||
return limit |
|
||||
|
|
||||
def get_history(self, hash168, limit=1000): |
|
||||
'''Generator that returns an unpruned, sorted list of (tx_hash, |
|
||||
height) tuples of transactions that touched the address, |
|
||||
earliest in the blockchain first. Includes both spending and |
|
||||
receiving transactions. By default yields at most 1000 entries. |
|
||||
Set limit to None to get them all. |
|
||||
''' |
|
||||
limit = self.resolve_limit(limit) |
|
||||
prefix = b'H' + hash168 |
|
||||
for key, hist in self.db.iterator(prefix=prefix): |
|
||||
a = array.array('I') |
|
||||
a.frombytes(hist) |
|
||||
for tx_num in a: |
|
||||
if limit == 0: |
|
||||
return |
|
||||
yield self.get_tx_hash(tx_num) |
|
||||
limit -= 1 |
|
||||
|
|
||||
def get_balance(self, hash168): |
|
||||
'''Returns the confirmed balance of an address.''' |
|
||||
return sum(utxo.value for utxo in self.get_utxos(hash168, limit=None)) |
|
||||
|
|
||||
def get_utxos(self, hash168, limit=1000): |
|
||||
'''Generator that yields all UTXOs for an address sorted in no |
|
||||
particular order. By default yields at most 1000 entries. |
|
||||
Set limit to None to get them all. |
|
||||
''' |
|
||||
limit = self.resolve_limit(limit) |
|
||||
unpack = struct.unpack |
|
||||
prefix = b'u' + hash168 |
|
||||
utxos = [] |
|
||||
for k, v in self.db.iterator(prefix=prefix): |
|
||||
(tx_pos, ) = unpack('<H', k[-2:]) |
|
||||
|
|
||||
for n in range(0, len(v), 12): |
|
||||
if limit == 0: |
|
||||
return |
|
||||
(tx_num, ) = unpack('<I', v[n:n+4]) |
|
||||
(value, ) = unpack('<Q', v[n+4:n+12]) |
|
||||
tx_hash, height = self.get_tx_hash(tx_num) |
|
||||
yield UTXO(tx_num, tx_pos, tx_hash, height, value) |
|
||||
limit -= 1 |
|
||||
|
|
||||
def get_utxos_sorted(self, hash168): |
|
||||
'''Returns all the UTXOs for an address sorted by height and |
|
||||
position in the block.''' |
|
||||
return sorted(self.get_utxos(hash168, limit=None)) |
|
@ -0,0 +1,216 @@ |
|||||
|
# See the file "LICENSE" for information about the copyright |
||||
|
# and warranty status of this software. |
||||
|
|
||||
|
import asyncio |
||||
|
import codecs |
||||
|
import json |
||||
|
import traceback |
||||
|
from functools import partial |
||||
|
|
||||
|
from lib.util import LoggedClass |
||||
|
from server.version import VERSION |
||||
|
|
||||
|
|
||||
|
class Error(Exception): |
||||
|
BAD_REQUEST = 1 |
||||
|
INTERNAL_ERROR = 2 |
||||
|
|
||||
|
|
||||
|
class JSONRPC(asyncio.Protocol, LoggedClass): |
||||
|
|
||||
|
def __init__(self, controller): |
||||
|
super().__init__() |
||||
|
self.controller = controller |
||||
|
self.parts = [] |
||||
|
|
||||
|
def connection_made(self, transport): |
||||
|
self.transport = transport |
||||
|
peername = transport.get_extra_info('peername') |
||||
|
self.logger.info('connection from {}'.format(peername)) |
||||
|
self.controller.add_session(self) |
||||
|
|
||||
|
def connection_lost(self, exc): |
||||
|
self.logger.info('disconnected') |
||||
|
self.controller.remove_session(self) |
||||
|
|
||||
|
def data_received(self, data): |
||||
|
while True: |
||||
|
npos = data.find(ord('\n')) |
||||
|
if npos == -1: |
||||
|
break |
||||
|
tail, data = data[:npos], data[npos + 1:] |
||||
|
parts = self.parts |
||||
|
self.parts = [] |
||||
|
parts.append(tail) |
||||
|
self.decode_message(b''.join(parts)) |
||||
|
|
||||
|
if data: |
||||
|
self.parts.append(data) |
||||
|
|
||||
|
def decode_message(self, message): |
||||
|
'''Message is a binary message.''' |
||||
|
try: |
||||
|
message = json.loads(message.decode()) |
||||
|
except Exception as e: |
||||
|
self.logger.info('caught exception decoding message'.format(e)) |
||||
|
return |
||||
|
|
||||
|
job = self.request_handler(message) |
||||
|
self.controller.add_job(job) |
||||
|
|
||||
|
async def request_handler(self, request): |
||||
|
'''Called asynchronously.''' |
||||
|
error = result = None |
||||
|
try: |
||||
|
result = await self.json_handler(request) |
||||
|
except Error as e: |
||||
|
error = {'code': e.args[0], 'message': e.args[1]} |
||||
|
except asyncio.CancelledError: |
||||
|
raise |
||||
|
except Exception as e: |
||||
|
# This should be considered a bug and fixed |
||||
|
traceback.print_exc() |
||||
|
error = {'code': Error.INTERNAL_ERROR, 'message': str(e)} |
||||
|
|
||||
|
payload = {'id': request.get('id'), 'error': error, 'result': result} |
||||
|
try: |
||||
|
data = json.dumps(payload) + '\n' |
||||
|
except TypeError: |
||||
|
msg = 'cannot JSON encode response to request {}'.format(request) |
||||
|
self.logger.error(msg) |
||||
|
error = {'code': Error.INTERNAL_ERROR, 'message': msg} |
||||
|
payload = {'id': request.get('id'), 'error': error, 'result': None} |
||||
|
data = json.dumps(payload) + '\n' |
||||
|
self.transport.write(data.encode()) |
||||
|
|
||||
|
async def json_handler(self, request): |
||||
|
method = request.get('method') |
||||
|
handler = None |
||||
|
if isinstance(method, str): |
||||
|
handler_name = 'handle_{}'.format(method.replace('.', '_')) |
||||
|
handler = getattr(self, handler_name, None) |
||||
|
if not handler: |
||||
|
self.logger.info('unknown method: {}'.format(method)) |
||||
|
raise Error(Error.BAD_REQUEST, 'unknown method: {}'.format(method)) |
||||
|
params = request.get('params', []) |
||||
|
if not isinstance(params, list): |
||||
|
raise Error(Error.BAD_REQUEST, 'params should be an array') |
||||
|
return await handler(params) |
||||
|
|
||||
|
|
||||
|
class ElectrumX(JSONRPC): |
||||
|
'''A TCP server that handles incoming Electrum connections.''' |
||||
|
|
||||
|
def __init__(self, controller, daemon, env): |
||||
|
super().__init__(controller) |
||||
|
self.daemon = daemon |
||||
|
self.env = env |
||||
|
self.addresses = set() |
||||
|
self.subscribe_headers = False |
||||
|
|
||||
|
def params_to_hash168(self, params): |
||||
|
if len(params) != 1: |
||||
|
raise Error(Error.BAD_REQUEST, |
||||
|
'params should contain a single address') |
||||
|
address = params[0] |
||||
|
try: |
||||
|
return self.env.coin.address_to_hash168(address) |
||||
|
except: |
||||
|
raise Error(Error.BAD_REQUEST, |
||||
|
'invalid address: {}'.format(address)) |
||||
|
|
||||
|
async def handle_blockchain_address_get_history(self, params): |
||||
|
hash168 = self.params_to_hash168(params) |
||||
|
return self.controller.get_history(hash168) |
||||
|
|
||||
|
async def handle_blockchain_address_subscribe(self, params): |
||||
|
hash168 = self.params_to_hash168(params) |
||||
|
status = self.controller.address_status(hash168) |
||||
|
return status.hex() if status else None |
||||
|
|
||||
|
async def handle_blockchain_estimatefee(self, params): |
||||
|
result = await self.daemon.send_single('estimatefee', params) |
||||
|
return result |
||||
|
|
||||
|
async def handle_blockchain_headers_subscribe(self, params): |
||||
|
self.subscribe_headers = True |
||||
|
return self.controller.get_current_header() |
||||
|
|
||||
|
async def handle_blockchain_relayfee(self, params): |
||||
|
'''The minimum fee a low-priority tx must pay in order to be accepted |
||||
|
to this daemon's memory pool. |
||||
|
''' |
||||
|
net_info = await self.daemon.send_single('getnetworkinfo') |
||||
|
return net_info['relayfee'] |
||||
|
|
||||
|
async def handle_blockchain_transaction_get(self, params): |
||||
|
if len(params) != 1: |
||||
|
raise Error(Error.BAD_REQUEST, |
||||
|
'params should contain a transaction hash') |
||||
|
tx_hash = params[0] |
||||
|
return await self.daemon.send_single('getrawtransaction', (tx_hash, 0)) |
||||
|
|
||||
|
async def handle_blockchain_transaction_get_merkle(self, params): |
||||
|
if len(params) != 2: |
||||
|
raise Error(Error.BAD_REQUEST, |
||||
|
'params should contain a transaction hash and height') |
||||
|
tx_hash, height = params |
||||
|
return await self.controller.get_merkle(tx_hash, height) |
||||
|
|
||||
|
async def handle_server_banner(self, params): |
||||
|
'''Return the server banner.''' |
||||
|
banner = 'Welcome to Electrum!' |
||||
|
if self.env.banner_file: |
||||
|
try: |
||||
|
with codecs.open(self.env.banner_file, 'r', 'utf-8') as f: |
||||
|
banner = f.read() |
||||
|
except Exception as e: |
||||
|
self.logger.error('reading banner file {}: {}' |
||||
|
.format(self.env.banner_file, e)) |
||||
|
return banner |
||||
|
|
||||
|
async def handle_server_donation_address(self, params): |
||||
|
'''Return the donation address as a string. |
||||
|
|
||||
|
If none is specified return the empty string. |
||||
|
''' |
||||
|
return self.env.donation_address |
||||
|
|
||||
|
async def handle_server_peers_subscribe(self, params): |
||||
|
'''Returns the peer (ip, host, ports) tuples. |
||||
|
|
||||
|
Despite the name electrum-server does not treat this as a |
||||
|
subscription. |
||||
|
''' |
||||
|
peers = self.controller.get_peers() |
||||
|
return tuple(peers.values()) |
||||
|
|
||||
|
async def handle_server_version(self, params): |
||||
|
'''Return the server version as a string.''' |
||||
|
return VERSION |
||||
|
|
||||
|
|
||||
|
class LocalRPC(JSONRPC): |
||||
|
'''A local TCP RPC server for querying status.''' |
||||
|
|
||||
|
async def handle_getinfo(self, params): |
||||
|
return { |
||||
|
'blocks': self.controller.height(), |
||||
|
'peers': len(self.controller.get_peers()), |
||||
|
'sessions': len(self.controller.sessions), |
||||
|
'watched': sum(len(s.addresses) for s in self.controller.sessions |
||||
|
if isinstance(s, ElectrumX)), |
||||
|
'cached': 0, |
||||
|
} |
||||
|
|
||||
|
async def handle_sessions(self, params): |
||||
|
return [] |
||||
|
|
||||
|
async def handle_numsessions(self, params): |
||||
|
return len(self.controller.sessions) |
||||
|
|
||||
|
async def handle_peers(self, params): |
||||
|
return tuple(self.controller.get_peers().keys()) |
||||
|
|
||||
|
async def handle_numpeers(self, params): |
||||
|
return len(self.controller.get_peers()) |
@ -1,174 +0,0 @@ |
|||||
# See the file "LICENSE" for information about the copyright |
|
||||
# and warranty status of this software. |
|
||||
|
|
||||
import asyncio |
|
||||
import json |
|
||||
import logging |
|
||||
import os |
|
||||
import signal |
|
||||
import time |
|
||||
from functools import partial |
|
||||
|
|
||||
import aiohttp |
|
||||
|
|
||||
from server.db import DB |
|
||||
|
|
||||
|
|
||||
class Server(object): |
|
||||
|
|
||||
def __init__(self, env): |
|
||||
self.env = env |
|
||||
self.db = DB(env) |
|
||||
self.block_cache = BlockCache(env, self.db) |
|
||||
self.tasks = [ |
|
||||
asyncio.ensure_future(self.block_cache.catch_up()), |
|
||||
asyncio.ensure_future(self.block_cache.process_cache()), |
|
||||
] |
|
||||
|
|
||||
loop = asyncio.get_event_loop() |
|
||||
for signame in ('SIGINT', 'SIGTERM'): |
|
||||
loop.add_signal_handler(getattr(signal, signame), |
|
||||
partial(self.on_signal, signame)) |
|
||||
|
|
||||
def on_signal(self, signame): |
|
||||
logging.warning('received {} signal, preparing to shut down' |
|
||||
.format(signame)) |
|
||||
for task in self.tasks: |
|
||||
task.cancel() |
|
||||
|
|
||||
def async_tasks(self): |
|
||||
return self.tasks |
|
||||
|
|
||||
|
|
||||
class BlockCache(object): |
|
||||
'''Requests blocks ahead of time from the daemon. Serves them |
|
||||
to the blockchain processor.''' |
|
||||
|
|
||||
def __init__(self, env, db): |
|
||||
self.logger = logging.getLogger('BlockCache') |
|
||||
self.logger.setLevel(logging.INFO) |
|
||||
|
|
||||
self.db = db |
|
||||
self.rpc_url = env.rpc_url |
|
||||
# Cache target size is in MB. Has little effect on sync time. |
|
||||
self.cache_limit = 10 |
|
||||
self.daemon_height = 0 |
|
||||
self.fetched_height = db.height |
|
||||
# Blocks stored in reverse order. Next block is at end of list. |
|
||||
self.blocks = [] |
|
||||
self.recent_sizes = [] |
|
||||
self.ave_size = 0 |
|
||||
|
|
||||
self.logger.info('using RPC URL {}'.format(self.rpc_url)) |
|
||||
|
|
||||
async def process_cache(self): |
|
||||
while True: |
|
||||
await asyncio.sleep(1) |
|
||||
while self.blocks: |
|
||||
self.db.process_block(self.blocks.pop(), self.daemon_height) |
|
||||
# Release asynchronous block fetching |
|
||||
await asyncio.sleep(0) |
|
||||
|
|
||||
async def catch_up(self): |
|
||||
self.logger.info('catching up, block cache limit {:d}MB...' |
|
||||
.format(self.cache_limit)) |
|
||||
|
|
||||
try: |
|
||||
while await self.maybe_prefill(): |
|
||||
await asyncio.sleep(1) |
|
||||
self.logger.info('caught up to height {:d}' |
|
||||
.format(self.daemon_height)) |
|
||||
finally: |
|
||||
self.db.flush(self.daemon_height, True) |
|
||||
|
|
||||
def cache_used(self): |
|
||||
return sum(len(block) for block in self.blocks) |
|
||||
|
|
||||
def prefill_count(self, room): |
|
||||
count = 0 |
|
||||
if self.ave_size: |
|
||||
count = room // self.ave_size |
|
||||
return max(count, 10) |
|
||||
|
|
||||
async def maybe_prefill(self): |
|
||||
'''Returns False to stop. True to sleep a while for asynchronous |
|
||||
processing.''' |
|
||||
cache_limit = self.cache_limit * 1024 * 1024 |
|
||||
while True: |
|
||||
cache_used = self.cache_used() |
|
||||
if cache_used > cache_limit: |
|
||||
return True |
|
||||
|
|
||||
# Keep going by getting a whole new cache_limit of blocks |
|
||||
self.daemon_height = await self.send_single('getblockcount') |
|
||||
max_count = min(self.daemon_height - self.fetched_height, 4000) |
|
||||
count = min(max_count, self.prefill_count(cache_limit)) |
|
||||
if not count: |
|
||||
return False # Done catching up |
|
||||
|
|
||||
first = self.fetched_height + 1 |
|
||||
param_lists = [[height] for height in range(first, first + count)] |
|
||||
hashes = await self.send_vector('getblockhash', param_lists) |
|
||||
|
|
||||
# Hashes is an array of hex strings |
|
||||
param_lists = [(h, False) for h in hashes] |
|
||||
blocks = await self.send_vector('getblock', param_lists) |
|
||||
self.fetched_height += count |
|
||||
|
|
||||
# Convert hex string to bytes and put in memoryview |
|
||||
blocks = [bytes.fromhex(block) for block in blocks] |
|
||||
# Reverse order and place at front of list |
|
||||
self.blocks = list(reversed(blocks)) + self.blocks |
|
||||
|
|
||||
# Keep 50 most recent block sizes for fetch count estimation |
|
||||
sizes = [len(block) for block in blocks] |
|
||||
self.recent_sizes.extend(sizes) |
|
||||
excess = len(self.recent_sizes) - 50 |
|
||||
if excess > 0: |
|
||||
self.recent_sizes = self.recent_sizes[excess:] |
|
||||
self.ave_size = sum(self.recent_sizes) // len(self.recent_sizes) |
|
||||
|
|
||||
async def send_single(self, method, params=None): |
|
||||
payload = {'method': method} |
|
||||
if params: |
|
||||
payload['params'] = params |
|
||||
result, = await self.send((payload, )) |
|
||||
return result |
|
||||
|
|
||||
async def send_many(self, mp_pairs): |
|
||||
payload = [{'method': method, 'params': params} |
|
||||
for method, params in mp_pairs] |
|
||||
return await self.send(payload) |
|
||||
|
|
||||
async def send_vector(self, method, params_list): |
|
||||
payload = [{'method': method, 'params': params} |
|
||||
for params in params_list] |
|
||||
return await self.send(payload) |
|
||||
|
|
||||
async def send(self, payload): |
|
||||
assert isinstance(payload, (tuple, list)) |
|
||||
data = json.dumps(payload) |
|
||||
while True: |
|
||||
try: |
|
||||
async with aiohttp.request('POST', self.rpc_url, |
|
||||
data = data) as resp: |
|
||||
result = await resp.json() |
|
||||
except asyncio.CancelledError: |
|
||||
raise |
|
||||
except Exception as e: |
|
||||
msg = 'aiohttp error: {}'.format(e) |
|
||||
secs = 3 |
|
||||
else: |
|
||||
errs = tuple(item['error'] for item in result) |
|
||||
if not any(errs): |
|
||||
return tuple(item['result'] for item in result) |
|
||||
if any(err.get('code') == -28 for err in errs): |
|
||||
msg = 'daemon still warming up.' |
|
||||
secs = 30 |
|
||||
else: |
|
||||
msg = 'daemon errors: {}'.format(errs) |
|
||||
secs = 3 |
|
||||
|
|
||||
self.logger.error('{}. Sleeping {:d}s and trying again...' |
|
||||
.format(msg, secs)) |
|
||||
await asyncio.sleep(secs) |
|
@ -0,0 +1 @@ |
|||||
|
VERSION = "ElectrumX 0.02" |
Loading…
Reference in new issue