lbry-sdk/torba/baseledger.py
2018-05-25 09:54:01 -04:00

469 lines
17 KiB
Python

import os
import hashlib
from binascii import hexlify, unhexlify
from typing import List, Dict, Type
from operator import itemgetter
from twisted.internet import threads, defer, task, reactor
from torba.account import Account, AccountsView
from torba.basecoin import BaseCoin
from torba.basetransaction import BaseTransaction
from torba.basenetwork import BaseNetwork
from torba.stream import StreamController, execute_serially
from torba.util import hex_to_int, int_to_hex, rev_hex, hash_encode
from torba.hash import double_sha256, pow_hash
class Address:
def __init__(self, pubkey_hash):
self.pubkey_hash = pubkey_hash
self.transactions = [] # type: List[BaseTransaction]
def __iter__(self):
return iter(self.transactions)
def __len__(self):
return len(self.transactions)
def add_transaction(self, transaction):
self.transactions.append(transaction)
def get_unspent_utxos(self):
inputs, outputs, utxos = [], [], []
for tx in self:
for txi in tx.inputs:
inputs.append((txi.output_txid, txi.output_index))
for txo in tx.outputs:
if txo.script.is_pay_pubkey_hash and txo.script.values['pubkey_hash'] == self.pubkey_hash:
outputs.append((txo, txo.transaction.hash, txo.index))
for output in set(outputs):
if output[1:] not in inputs:
yield output[0]
class BaseLedger:
# coin_class is automatically set by BaseCoin metaclass
# when it creates the Coin classes, there is a 1..1 relationship
# between a coin and a ledger (at the class level) but a 1..* relationship
# at instance level. Only one Ledger instance should exist per coin class,
# but many coin instances can exist linking back to the single Ledger instance.
coin_class = None # type: Type[BaseCoin]
network_class = None # type: Type[BaseNetwork]
verify_bits_to_target = True
def __init__(self, accounts, config=None, network=None, db=None):
self.accounts = accounts # type: AccountsView
self.config = config or {}
self.db = db
self.addresses = {} # type: Dict[str, Address]
self.transactions = {} # type: Dict[str, BaseTransaction]
self.headers = Headers(self)
self._on_transaction_controller = StreamController()
self.on_transaction = self._on_transaction_controller.stream
self.network = network or self.network_class(self.config)
self.network.on_header.listen(self.process_header)
self.network.on_status.listen(self.process_status)
@property
def transaction_class(self):
return self.coin_class.transaction_class
@classmethod
def from_json(cls, json_dict):
return cls(json_dict)
@defer.inlineCallbacks
def load(self):
txs = yield self.db.get_transactions()
for tx_hash, raw, height in txs:
self.transactions[tx_hash] = self.transaction_class(raw, height)
txios = yield self.db.get_transaction_inputs_and_outputs()
for tx_hash, address_hash, input_output, amount, height in txios:
tx = self.transactions[tx_hash]
address = self.addresses.get(address_hash)
if address is None:
address = self.addresses[address_hash] = Address(self.coin_class.address_to_hash160(address_hash))
tx.add_txio(address, input_output, amount)
address.add_transaction(tx)
def is_address_old(self, address, age_limit=2):
age = -1
for tx in self.get_transactions(address, []):
if tx.height == 0:
tx_age = 0
else:
tx_age = self.headers.height - tx.height + 1
if tx_age > age:
age = tx_age
return age > age_limit
def add_transaction(self, address, transaction): # type: (str, BaseTransaction) -> None
if address not in self.addresses:
self.addresses[address] = Address(self.coin_class.address_to_hash160(address))
self.addresses[address].add_transaction(transaction)
self.transactions.setdefault(transaction.id, transaction)
self._on_transaction_controller.add(transaction)
def has_address(self, address):
return address in self.addresses
def get_transaction(self, tx_hash, *args):
return self.transactions.get(tx_hash, *args)
def get_transactions(self, address, *args):
return self.addresses.get(address, *args)
def get_status(self, address):
hashes = [
'{}:{}:'.format(hexlify(tx.hash), tx.height).encode()
for tx in self.get_transactions(address, []) if tx.height is not None
]
if hashes:
return hexlify(hashlib.sha256(b''.join(hashes)).digest())
def has_transaction(self, tx_hash):
return tx_hash in self.transactions
def get_least_used_address(self, addresses, max_transactions=100):
transaction_counts = []
for address in addresses:
transactions = self.get_transactions(address, [])
tx_count = len(transactions)
if tx_count == 0:
return address
elif tx_count >= max_transactions:
continue
else:
transaction_counts.append((address, tx_count))
if transaction_counts:
transaction_counts.sort(key=itemgetter(1))
return transaction_counts[0]
def get_unspent_outputs(self, address):
if address in self.addresses:
return list(self.addresses[address].get_unspent_utxos())
return []
@defer.inlineCallbacks
def start(self):
first_connection = self.network.on_connected.first
self.network.start()
yield first_connection
self.headers.touch()
yield self.update_headers()
yield self.network.subscribe_headers()
yield self.update_accounts()
def stop(self):
return self.network.stop()
@execute_serially
@defer.inlineCallbacks
def update_headers(self):
while True:
height_sought = len(self.headers)
headers = yield self.network.get_headers(height_sought)
print("received {} headers starting at {} height".format(headers['count'], height_sought))
#log.info("received {} headers starting at {} height".format(headers['count'], height_sought))
if headers['count'] <= 0:
break
yield self.headers.connect(height_sought, unhexlify(headers['hex']))
@defer.inlineCallbacks
def process_header(self, response):
header = response[0]
if self.update_headers.is_running:
return
if header['height'] == len(self.headers):
# New header from network directly connects after the last local header.
yield self.headers.connect(len(self.headers), unhexlify(header['hex']))
elif header['height'] > len(self.headers):
# New header is several heights ahead of local, do download instead.
yield self.update_headers()
@execute_serially
def update_accounts(self):
return defer.DeferredList([
self.update_account(a) for a in self.accounts
])
@defer.inlineCallbacks
def update_account(self, account): # type: (Account) -> defer.Defferred
# Before subscribing, download history for any addresses that don't have any,
# this avoids situation where we're getting status updates to addresses we know
# need to update anyways. Continue to get history and create more addresses until
# all missing addresses are created and history for them is fully restored.
account.ensure_enough_addresses()
addresses = list(account.addresses_without_history())
while addresses:
yield defer.DeferredList([
self.update_history(a) for a in addresses
])
addresses = account.ensure_enough_addresses()
# By this point all of the addresses should be restored and we
# can now subscribe all of them to receive updates.
yield defer.DeferredList([
self.subscribe_history(address)
for address in account.addresses
])
@defer.inlineCallbacks
def update_history(self, address):
history = yield self.network.get_history(address)
for hash in map(itemgetter('tx_hash'), history):
transaction = self.get_transaction(hash)
if not transaction:
raw = yield self.network.get_transaction(hash)
transaction = self.transaction_class(unhexlify(raw))
self.add_transaction(address, transaction)
@defer.inlineCallbacks
def subscribe_history(self, address):
status = yield self.network.subscribe_address(address)
if status != self.get_status(address):
yield self.update_history(address)
def process_status(self, response):
address, status = response
if status != self.get_status(address):
task.deferLater(reactor, 0, self.update_history, address)
def broadcast(self, tx):
return self.network.broadcast(hexlify(tx.raw))
class Headers:
def __init__(self, ledger):
self.ledger = ledger
self._size = None
self._on_change_controller = StreamController()
self.on_changed = self._on_change_controller.stream
@property
def path(self):
wallet_path = self.ledger.config.get('wallet_path', '')
filename = '{}_headers'.format(self.ledger.coin_class.get_id())
return os.path.join(wallet_path, filename)
def touch(self):
if not os.path.exists(self.path):
with open(self.path, 'wb'):
pass
@property
def height(self):
return len(self) - 1
def sync_read_length(self):
return os.path.getsize(self.path) // self.ledger.header_size
def sync_read_header(self, height):
if 0 <= height < len(self):
with open(self.path, 'rb') as f:
f.seek(height * self.ledger.header_size)
return f.read(self.ledger.header_size)
def __len__(self):
if self._size is None:
self._size = self.sync_read_length()
return self._size
def __getitem__(self, height):
assert not isinstance(height, slice),\
"Slicing of header chain has not been implemented yet."
header = self.sync_read_header(height)
return self._deserialize(height, header)
@execute_serially
@defer.inlineCallbacks
def connect(self, start, headers):
yield threads.deferToThread(self._sync_connect, start, headers)
def _sync_connect(self, start, headers):
previous_header = None
for header in self._iterate_headers(start, headers):
height = header['block_height']
if previous_header is None and height > 0:
previous_header = self[height-1]
self._verify_header(height, header, previous_header)
previous_header = header
with open(self.path, 'r+b') as f:
f.seek(start * self.ledger.header_size)
f.write(headers)
f.truncate()
_old_size = self._size
self._size = self.sync_read_length()
change = self._size - _old_size
#log.info('saved {} header blocks'.format(change))
self._on_change_controller.add(change)
def _iterate_headers(self, height, headers):
assert len(headers) % self.ledger.header_size == 0
for idx in range(len(headers) // self.ledger.header_size):
start, end = idx * self.ledger.header_size, (idx + 1) * self.ledger.header_size
header = headers[start:end]
yield self._deserialize(height+idx, header)
def _verify_header(self, height, header, previous_header):
previous_hash = self._hash_header(previous_header)
assert previous_hash == header['prev_block_hash'], \
"prev hash mismatch: {} vs {}".format(previous_hash, header['prev_block_hash'])
bits, target = self._calculate_lbry_next_work_required(height, previous_header, header)
assert bits == header['bits'], \
"bits mismatch: {} vs {} (hash: {})".format(
bits, header['bits'], self._hash_header(header))
_pow_hash = self._pow_hash_header(header)
assert int(b'0x' + _pow_hash, 16) <= target, \
"insufficient proof of work: {} vs target {}".format(
int(b'0x' + _pow_hash, 16), target)
@staticmethod
def _serialize(header):
return b''.join([
int_to_hex(header['version'], 4),
rev_hex(header['prev_block_hash']),
rev_hex(header['merkle_root']),
rev_hex(header['claim_trie_root']),
int_to_hex(int(header['timestamp']), 4),
int_to_hex(int(header['bits']), 4),
int_to_hex(int(header['nonce']), 4)
])
@staticmethod
def _deserialize(height, header):
return {
'version': hex_to_int(header[0:4]),
'prev_block_hash': hash_encode(header[4:36]),
'merkle_root': hash_encode(header[36:68]),
'claim_trie_root': hash_encode(header[68:100]),
'timestamp': hex_to_int(header[100:104]),
'bits': hex_to_int(header[104:108]),
'nonce': hex_to_int(header[108:112]),
'block_height': height
}
def _hash_header(self, header):
if header is None:
return b'0' * 64
return hash_encode(double_sha256(unhexlify(self._serialize(header))))
def _pow_hash_header(self, header):
if header is None:
return b'0' * 64
return hash_encode(pow_hash(unhexlify(self._serialize(header))))
def _calculate_lbry_next_work_required(self, height, first, last):
""" See: lbrycrd/src/lbry.cpp """
if height == 0:
return self.ledger.genesis_bits, self.ledger.max_target
if self.ledger.verify_bits_to_target:
bits = last['bits']
bitsN = (bits >> 24) & 0xff
assert 0x03 <= bitsN <= 0x1f, \
"First part of bits should be in [0x03, 0x1d], but it was {}".format(hex(bitsN))
bitsBase = bits & 0xffffff
assert 0x8000 <= bitsBase <= 0x7fffff, \
"Second part of bits should be in [0x8000, 0x7fffff] but it was {}".format(bitsBase)
# new target
retargetTimespan = self.ledger.target_timespan
nActualTimespan = last['timestamp'] - first['timestamp']
nModulatedTimespan = retargetTimespan + (nActualTimespan - retargetTimespan) // 8
nMinTimespan = retargetTimespan - (retargetTimespan // 8)
nMaxTimespan = retargetTimespan + (retargetTimespan // 2)
# Limit adjustment step
if nModulatedTimespan < nMinTimespan:
nModulatedTimespan = nMinTimespan
elif nModulatedTimespan > nMaxTimespan:
nModulatedTimespan = nMaxTimespan
# Retarget
bnPowLimit = _ArithUint256(self.ledger.max_target)
bnNew = _ArithUint256.SetCompact(last['bits'])
bnNew *= nModulatedTimespan
bnNew //= nModulatedTimespan
if bnNew > bnPowLimit:
bnNew = bnPowLimit
return bnNew.GetCompact(), bnNew._value
class _ArithUint256:
""" See: lbrycrd/src/arith_uint256.cpp """
def __init__(self, value):
self._value = value
def __str__(self):
return hex(self._value)
@staticmethod
def fromCompact(nCompact):
"""Convert a compact representation into its value"""
nSize = nCompact >> 24
# the lower 23 bits
nWord = nCompact & 0x007fffff
if nSize <= 3:
return nWord >> 8 * (3 - nSize)
else:
return nWord << 8 * (nSize - 3)
@classmethod
def SetCompact(cls, nCompact):
return cls(cls.fromCompact(nCompact))
def bits(self):
"""Returns the position of the highest bit set plus one."""
bn = bin(self._value)[2:]
for i, d in enumerate(bn):
if d:
return (len(bn) - i) + 1
return 0
def GetLow64(self):
return self._value & 0xffffffffffffffff
def GetCompact(self):
"""Convert a value into its compact representation"""
nSize = (self.bits() + 7) // 8
nCompact = 0
if nSize <= 3:
nCompact = self.GetLow64() << 8 * (3 - nSize)
else:
bn = _ArithUint256(self._value >> 8 * (nSize - 3))
nCompact = bn.GetLow64()
# The 0x00800000 bit denotes the sign.
# Thus, if it is already set, divide the mantissa by 256 and increase the exponent.
if nCompact & 0x00800000:
nCompact >>= 8
nSize += 1
assert (nCompact & ~0x007fffff) == 0
assert nSize < 256
nCompact |= nSize << 24
return nCompact
def __mul__(self, x):
# Take the mod because we are limited to an unsigned 256 bit number
return _ArithUint256((self._value * x) % 2 ** 256)
def __ifloordiv__(self, x):
self._value = (self._value // x)
return self
def __gt__(self, x):
return self._value > x._value