mirror of
https://github.com/aljazceru/python-teos.git
synced 2025-12-18 14:44:21 +01:00
pisa -> teos
This commit is contained in:
38
teos/__init__.py
Normal file
38
teos/__init__.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import os
|
||||
import teos.conf as conf
|
||||
from common.tools import check_conf_fields, setup_logging, extend_paths, setup_data_folder
|
||||
from teos.utils.auth_proxy import AuthServiceProxy
|
||||
|
||||
HOST = "localhost"
|
||||
PORT = 9814
|
||||
LOG_PREFIX = "teos"
|
||||
|
||||
# Load config fields
|
||||
conf_fields = {
|
||||
"BTC_RPC_USER": {"value": conf.BTC_RPC_USER, "type": str},
|
||||
"BTC_RPC_PASSWD": {"value": conf.BTC_RPC_PASSWD, "type": str},
|
||||
"BTC_RPC_HOST": {"value": conf.BTC_RPC_HOST, "type": str},
|
||||
"BTC_RPC_PORT": {"value": conf.BTC_RPC_PORT, "type": int},
|
||||
"BTC_NETWORK": {"value": conf.BTC_NETWORK, "type": str},
|
||||
"FEED_PROTOCOL": {"value": conf.FEED_PROTOCOL, "type": str},
|
||||
"FEED_ADDR": {"value": conf.FEED_ADDR, "type": str},
|
||||
"FEED_PORT": {"value": conf.FEED_PORT, "type": int},
|
||||
"DATA_FOLDER": {"value": conf.DATA_FOLDER, "type": str},
|
||||
"MAX_APPOINTMENTS": {"value": conf.MAX_APPOINTMENTS, "type": int},
|
||||
"EXPIRY_DELTA": {"value": conf.EXPIRY_DELTA, "type": int},
|
||||
"MIN_TO_SELF_DELAY": {"value": conf.MIN_TO_SELF_DELAY, "type": int},
|
||||
"SERVER_LOG_FILE": {"value": conf.SERVER_LOG_FILE, "type": str, "path": True},
|
||||
"TEOS_SECRET_KEY": {"value": conf.TEOS_SECRET_KEY, "type": str, "path": True},
|
||||
"DB_PATH": {"value": conf.DB_PATH, "type": str, "path": True},
|
||||
}
|
||||
|
||||
# Expand user (~) if found and check fields are correct
|
||||
conf_fields["DATA_FOLDER"]["value"] = os.path.expanduser(conf_fields["DATA_FOLDER"]["value"])
|
||||
# Extend relative paths
|
||||
conf_fields = extend_paths(conf_fields["DATA_FOLDER"]["value"], conf_fields)
|
||||
|
||||
# Sanity check fields and build config dictionary
|
||||
config = check_conf_fields(conf_fields)
|
||||
|
||||
setup_data_folder(config.get("DATA_FOLDER"))
|
||||
setup_logging(config.get("SERVER_LOG_FILE"), LOG_PREFIX)
|
||||
197
teos/api.py
Normal file
197
teos/api.py
Normal file
@@ -0,0 +1,197 @@
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from flask import Flask, request, abort, jsonify
|
||||
|
||||
from teos import HOST, PORT, LOG_PREFIX
|
||||
from common.logger import Logger
|
||||
from teos.inspector import Inspector
|
||||
from common.appointment import Appointment
|
||||
|
||||
from common.constants import HTTP_OK, HTTP_BAD_REQUEST, HTTP_SERVICE_UNAVAILABLE, LOCATOR_LEN_HEX
|
||||
|
||||
|
||||
# ToDo: #5-add-async-to-api
|
||||
app = Flask(__name__)
|
||||
logger = Logger(actor="API", log_name_prefix=LOG_PREFIX)
|
||||
|
||||
|
||||
class API:
|
||||
def __init__(self, watcher, config):
|
||||
self.watcher = watcher
|
||||
self.config = config
|
||||
|
||||
def add_appointment(self):
|
||||
"""
|
||||
Main endpoint of the Watchtower.
|
||||
|
||||
The client sends requests (appointments) to this endpoint to request a job to the Watchtower. Requests must be
|
||||
json encoded and contain an ``appointment`` field and optionally a ``signature`` and ``public_key`` fields.
|
||||
|
||||
Returns:
|
||||
:obj:`tuple`: A tuple containing the response (``json``) and response code (``int``). For accepted
|
||||
appointments, the ``rcode`` is always 0 and the response contains the signed receipt. For rejected
|
||||
appointments, the ``rcode`` is a negative value and the response contains the error message. Error messages
|
||||
can be found at :mod:`Errors <teos.errors>`.
|
||||
"""
|
||||
|
||||
# Getting the real IP if the server is behind a reverse proxy
|
||||
remote_addr = request.environ.get("HTTP_X_REAL_IP")
|
||||
if not remote_addr:
|
||||
remote_addr = request.environ.get("REMOTE_ADDR")
|
||||
|
||||
logger.info("Received add_appointment request", from_addr="{}".format(remote_addr))
|
||||
|
||||
# FIXME: Logging every request so we can get better understanding of bugs in the alpha
|
||||
logger.debug("Request details", data="{}".format(request.data))
|
||||
|
||||
if request.is_json:
|
||||
# Check content type once if properly defined
|
||||
request_data = json.loads(request.get_json())
|
||||
inspector = Inspector(self.config)
|
||||
appointment = inspector.inspect(
|
||||
request_data.get("appointment"), request_data.get("signature"), request_data.get("public_key")
|
||||
)
|
||||
|
||||
error = None
|
||||
response = None
|
||||
|
||||
if type(appointment) == Appointment:
|
||||
appointment_added, signature = self.watcher.add_appointment(appointment)
|
||||
|
||||
if appointment_added:
|
||||
rcode = HTTP_OK
|
||||
response = {"locator": appointment.locator, "signature": signature}
|
||||
|
||||
else:
|
||||
rcode = HTTP_SERVICE_UNAVAILABLE
|
||||
error = "appointment rejected"
|
||||
|
||||
elif type(appointment) == tuple:
|
||||
rcode = HTTP_BAD_REQUEST
|
||||
error = "appointment rejected. Error {}: {}".format(appointment[0], appointment[1])
|
||||
|
||||
else:
|
||||
# We should never end up here, since inspect only returns appointments or tuples. Just in case.
|
||||
rcode = HTTP_BAD_REQUEST
|
||||
error = "appointment rejected. Request does not match the standard"
|
||||
|
||||
else:
|
||||
rcode = HTTP_BAD_REQUEST
|
||||
error = "appointment rejected. Request is not json encoded"
|
||||
response = None
|
||||
|
||||
logger.info(
|
||||
"Sending response and disconnecting", from_addr="{}".format(remote_addr), response=response, error=error
|
||||
)
|
||||
|
||||
if error is None:
|
||||
return jsonify(response), rcode
|
||||
else:
|
||||
return jsonify({"error": error}), rcode
|
||||
|
||||
# FIXME: THE NEXT TWO API ENDPOINTS ARE FOR TESTING AND SHOULD BE REMOVED / PROPERLY MANAGED BEFORE PRODUCTION!
|
||||
# ToDo: #17-add-api-keys
|
||||
def get_appointment(self):
|
||||
"""
|
||||
Gives information about a given appointment state in the Watchtower.
|
||||
|
||||
The information is requested by ``locator``.
|
||||
|
||||
Returns:
|
||||
:obj:`dict`: A json formatted dictionary containing information about the requested appointment.
|
||||
|
||||
A ``status`` flag is added to the data provided by either the :obj:`Watcher <teos.watcher.Watcher>` or the
|
||||
:obj:`Responder <teos.responder.Responder>` that signals the status of the appointment.
|
||||
|
||||
- Appointments hold by the :obj:`Watcher <teos.watcher.Watcher>` are flagged as ``being_watched``.
|
||||
- Appointments hold by the :obj:`Responder <teos.responder.Responder>` are flagged as ``dispute_triggered``.
|
||||
- Unknown appointments are flagged as ``not_found``.
|
||||
"""
|
||||
|
||||
# Getting the real IP if the server is behind a reverse proxy
|
||||
remote_addr = request.environ.get("HTTP_X_REAL_IP")
|
||||
if not remote_addr:
|
||||
remote_addr = request.environ.get("REMOTE_ADDR")
|
||||
|
||||
locator = request.args.get("locator")
|
||||
response = []
|
||||
|
||||
logger.info("Received get_appointment request", from_addr="{}".format(remote_addr), locator=locator)
|
||||
|
||||
# ToDo: #15-add-system-monitor
|
||||
if not isinstance(locator, str) or len(locator) != LOCATOR_LEN_HEX:
|
||||
response.append({"locator": locator, "status": "not_found"})
|
||||
return jsonify(response)
|
||||
|
||||
locator_map = self.watcher.db_manager.load_locator_map(locator)
|
||||
triggered_appointments = self.watcher.db_manager.load_all_triggered_flags()
|
||||
|
||||
if locator_map is not None:
|
||||
for uuid in locator_map:
|
||||
if uuid not in triggered_appointments:
|
||||
appointment_data = self.watcher.db_manager.load_watcher_appointment(uuid)
|
||||
|
||||
if appointment_data is not None:
|
||||
appointment_data["status"] = "being_watched"
|
||||
response.append(appointment_data)
|
||||
|
||||
tracker_data = self.watcher.db_manager.load_responder_tracker(uuid)
|
||||
|
||||
if tracker_data is not None:
|
||||
tracker_data["status"] = "dispute_responded"
|
||||
response.append(tracker_data)
|
||||
|
||||
else:
|
||||
response.append({"locator": locator, "status": "not_found"})
|
||||
|
||||
response = jsonify(response)
|
||||
|
||||
return response
|
||||
|
||||
def get_all_appointments(self):
|
||||
"""
|
||||
Gives information about all the appointments in the Watchtower.
|
||||
|
||||
This endpoint should only be accessible by the administrator. Requests are only allowed from localhost.
|
||||
|
||||
Returns:
|
||||
:obj:`dict`: A json formatted dictionary containing all the appointments hold by the
|
||||
:obj:`Watcher <teos.watcher.Watcher>` (``watcher_appointments``) and by the
|
||||
:obj:`Responder <teos.responder.Responder>` (``responder_trackers``).
|
||||
|
||||
"""
|
||||
|
||||
# ToDo: #15-add-system-monitor
|
||||
response = None
|
||||
|
||||
if request.remote_addr in request.host or request.remote_addr == "127.0.0.1":
|
||||
watcher_appointments = self.watcher.db_manager.load_watcher_appointments()
|
||||
responder_trackers = self.watcher.db_manager.load_responder_trackers()
|
||||
|
||||
response = jsonify({"watcher_appointments": watcher_appointments, "responder_trackers": responder_trackers})
|
||||
|
||||
else:
|
||||
abort(404)
|
||||
|
||||
return response
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
This function starts the Flask server used to run the API. Adds all the routes to the functions listed above.
|
||||
"""
|
||||
|
||||
routes = {
|
||||
"/": (self.add_appointment, ["POST"]),
|
||||
"/get_appointment": (self.get_appointment, ["GET"]),
|
||||
"/get_all_appointments": (self.get_all_appointments, ["GET"]),
|
||||
}
|
||||
|
||||
for url, params in routes.items():
|
||||
app.add_url_rule(url, view_func=params[0], methods=params[1])
|
||||
|
||||
# Setting Flask log to ERROR only so it does not mess with out logging. Also disabling flask initial messages
|
||||
logging.getLogger("werkzeug").setLevel(logging.ERROR)
|
||||
os.environ["WERKZEUG_RUN_MAIN"] = "true"
|
||||
|
||||
app.run(host=HOST, port=PORT)
|
||||
212
teos/block_processor.py
Normal file
212
teos/block_processor.py
Normal file
@@ -0,0 +1,212 @@
|
||||
from common.logger import Logger
|
||||
|
||||
from teos import LOG_PREFIX
|
||||
from teos.tools import bitcoin_cli
|
||||
from teos.utils.auth_proxy import JSONRPCException
|
||||
|
||||
logger = Logger(actor="BlockProcessor", log_name_prefix=LOG_PREFIX)
|
||||
|
||||
|
||||
class BlockProcessor:
|
||||
"""
|
||||
The :class:`BlockProcessor` contains methods related to the blockchain. Most of its methods require communication
|
||||
with ``bitcoind``.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_block(block_hash):
|
||||
"""
|
||||
Gives a block given a block hash by querying ``bitcoind``.
|
||||
|
||||
Args:
|
||||
block_hash (:obj:`str`): The block hash to be queried.
|
||||
|
||||
Returns:
|
||||
:obj:`dict` or :obj:`None`: A dictionary containing the requested block data if the block is found.
|
||||
|
||||
Returns ``None`` otherwise.
|
||||
"""
|
||||
|
||||
try:
|
||||
block = bitcoin_cli().getblock(block_hash)
|
||||
|
||||
except JSONRPCException as e:
|
||||
block = None
|
||||
logger.error("Couldn't get block from bitcoind", error=e.error)
|
||||
|
||||
return block
|
||||
|
||||
@staticmethod
|
||||
def get_best_block_hash():
|
||||
"""
|
||||
Returns the hash of the current best chain tip.
|
||||
|
||||
Returns:
|
||||
:obj:`str` or :obj:`None`: The hash of the block if it can be found.
|
||||
|
||||
Returns ``None`` otherwise (not even sure this can actually happen).
|
||||
"""
|
||||
|
||||
try:
|
||||
block_hash = bitcoin_cli().getbestblockhash()
|
||||
|
||||
except JSONRPCException as e:
|
||||
block_hash = None
|
||||
logger.error("Couldn't get block hash", error=e.error)
|
||||
|
||||
return block_hash
|
||||
|
||||
@staticmethod
|
||||
def get_block_count():
|
||||
"""
|
||||
Returns the block height of the best chain.
|
||||
|
||||
Returns:
|
||||
:obj:`int` or :obj:`None`: The block height if it can be computed.
|
||||
|
||||
Returns ``None`` otherwise (not even sure this can actually happen).
|
||||
"""
|
||||
|
||||
try:
|
||||
block_count = bitcoin_cli().getblockcount()
|
||||
|
||||
except JSONRPCException as e:
|
||||
block_count = None
|
||||
logger.error("Couldn't get block count", error=e.error)
|
||||
|
||||
return block_count
|
||||
|
||||
@staticmethod
|
||||
def decode_raw_transaction(raw_tx):
|
||||
"""
|
||||
Deserializes a given raw transaction (hex encoded) and builds a dictionary representing it with all the
|
||||
associated metadata given by ``bitcoind`` (e.g. confirmation count).
|
||||
|
||||
Args:
|
||||
raw_tx (:obj:`str`): The hex representation of the transaction.
|
||||
|
||||
Returns:
|
||||
:obj:`dict` or :obj:`None`: The decoding of the given ``raw_tx`` if the transaction is well formatted.
|
||||
|
||||
Returns ``None`` otherwise.
|
||||
"""
|
||||
|
||||
try:
|
||||
tx = bitcoin_cli().decoderawtransaction(raw_tx)
|
||||
|
||||
except JSONRPCException as e:
|
||||
tx = None
|
||||
logger.error("Can't build transaction from decoded data", error=e.error)
|
||||
|
||||
return tx
|
||||
|
||||
@staticmethod
|
||||
def get_distance_to_tip(target_block_hash):
|
||||
"""
|
||||
Compute the distance between a given block hash and the best chain tip.
|
||||
|
||||
Args:
|
||||
target_block_hash (:obj:`str`): the hash of the target block (the one to compute the distance form the tip).
|
||||
|
||||
Returns:
|
||||
:obj:`int` or :obj:`None`: The distance between the target and the best chain tip is the target block can be
|
||||
found on the blockchain.
|
||||
|
||||
Returns ``None`` otherwise.
|
||||
"""
|
||||
|
||||
distance = None
|
||||
|
||||
chain_tip = BlockProcessor.get_best_block_hash()
|
||||
chain_tip_height = BlockProcessor.get_block(chain_tip).get("height")
|
||||
|
||||
target_block = BlockProcessor.get_block(target_block_hash)
|
||||
|
||||
if target_block is not None:
|
||||
target_block_height = target_block.get("height")
|
||||
|
||||
distance = chain_tip_height - target_block_height
|
||||
|
||||
return distance
|
||||
|
||||
@staticmethod
|
||||
def get_missed_blocks(last_know_block_hash):
|
||||
"""
|
||||
Compute the blocks between the current best chain tip and a given block hash (``last_know_block_hash``).
|
||||
|
||||
This method is used to fetch all the missed information when recovering from a crash.
|
||||
|
||||
Args:
|
||||
last_know_block_hash (:obj:`str`): the hash of the last known block.
|
||||
|
||||
Returns:
|
||||
:obj:`list`: A list of blocks between the last given block and the current best chain tip, starting from the
|
||||
child of ``last_know_block_hash``.
|
||||
"""
|
||||
|
||||
current_block_hash = BlockProcessor.get_best_block_hash()
|
||||
missed_blocks = []
|
||||
|
||||
while current_block_hash != last_know_block_hash and current_block_hash is not None:
|
||||
missed_blocks.append(current_block_hash)
|
||||
|
||||
current_block = BlockProcessor.get_block(current_block_hash)
|
||||
current_block_hash = current_block.get("previousblockhash")
|
||||
|
||||
return missed_blocks[::-1]
|
||||
|
||||
@staticmethod
|
||||
def is_block_in_best_chain(block_hash):
|
||||
"""
|
||||
Checks whether or not a given block is on the best chain. Blocks are identified by block_hash.
|
||||
|
||||
A block that is not in the best chain will either not exists (block = None) or have a confirmation count of
|
||||
-1 (implying that the block was forked out or the chain never grew from that one).
|
||||
|
||||
Args:
|
||||
block_hash(:obj:`str`): the hash of the block to be checked.
|
||||
|
||||
Returns:
|
||||
:obj:`bool`: ``True`` if the block is on the best chain, ``False`` otherwise.
|
||||
|
||||
Raises:
|
||||
KeyError: If the block cannot be found in the blockchain.
|
||||
"""
|
||||
|
||||
block = BlockProcessor.get_block(block_hash)
|
||||
|
||||
if block is None:
|
||||
# This should never happen as long as we are using the same node, since bitcoind never drops orphan blocks
|
||||
# and we have received this block from our node at some point.
|
||||
raise KeyError("Block not found")
|
||||
|
||||
if block.get("confirmations") != -1:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def find_last_common_ancestor(last_known_block_hash):
|
||||
"""
|
||||
Finds the last common ancestor between the current best chain tip and the last block known by us (older block).
|
||||
|
||||
This is useful to recover from a chain fork happening while offline (crash/shutdown).
|
||||
|
||||
Args:
|
||||
last_known_block_hash(:obj:`str`): the hash of the last know block.
|
||||
|
||||
Returns:
|
||||
:obj:`tuple`: A tuple (:obj:`str`:, :obj:`list`:) where the first item contains the hash of the last common
|
||||
ancestor and the second item contains the list of transactions from ``last_known_block_hash`` to
|
||||
``last_common_ancestor``.
|
||||
"""
|
||||
|
||||
target_block_hash = last_known_block_hash
|
||||
dropped_txs = []
|
||||
|
||||
while not BlockProcessor.is_block_in_best_chain(target_block_hash):
|
||||
block = BlockProcessor.get_block(target_block_hash)
|
||||
dropped_txs.extend(block.get("tx"))
|
||||
target_block_hash = block.get("previousblockhash")
|
||||
|
||||
return target_block_hash, dropped_txs
|
||||
138
teos/builder.py
Normal file
138
teos/builder.py
Normal file
@@ -0,0 +1,138 @@
|
||||
class Builder:
|
||||
"""
|
||||
The :class:`Builder` class is in charge or reconstructing data loaded from the database and build the data
|
||||
structures of the :obj:`Watcher <teos.watcher.Watcher>` and the :obj:`Responder <teos.responder.Responder>`.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_appointments(appointments_data):
|
||||
"""
|
||||
Builds an appointments dictionary (``uuid: Appointment``) and a locator_uuid_map (``locator: uuid``) given a
|
||||
dictionary of appointments from the database.
|
||||
|
||||
Args:
|
||||
appointments_data (:obj:`dict`): a dictionary of dictionaries representing all the
|
||||
:obj:`Watcher <teos.watcher.Watcher>` appointments stored in the database. The structure is as follows:
|
||||
|
||||
``{uuid: {locator: str, start_time: int, ...}, uuid: {locator:...}}``
|
||||
|
||||
Returns:
|
||||
:obj:`tuple`: A tuple with two dictionaries. ``appointments`` containing the appointment information in
|
||||
:obj:`Appointment <teos.appointment.Appointment>` objects and ``locator_uuid_map`` containing a map of
|
||||
appointment (``uuid:locator``).
|
||||
"""
|
||||
|
||||
appointments = {}
|
||||
locator_uuid_map = {}
|
||||
|
||||
for uuid, data in appointments_data.items():
|
||||
appointments[uuid] = {"locator": data.get("locator"), "end_time": data.get("end_time")}
|
||||
|
||||
if data.get("locator") in locator_uuid_map:
|
||||
locator_uuid_map[data.get("locator")].append(uuid)
|
||||
|
||||
else:
|
||||
locator_uuid_map[data.get("locator")] = [uuid]
|
||||
|
||||
return appointments, locator_uuid_map
|
||||
|
||||
@staticmethod
|
||||
def build_trackers(tracker_data):
|
||||
"""
|
||||
Builds a tracker dictionary (``uuid: TransactionTracker``) and a tx_tracker_map (``penalty_txid: uuid``) given
|
||||
a dictionary of trackers from the database.
|
||||
|
||||
Args:
|
||||
tracker_data (:obj:`dict`): a dictionary of dictionaries representing all the
|
||||
:mod:`Responder <teos.responder.Responder>` trackers stored in the database.
|
||||
The structure is as follows:
|
||||
|
||||
``{uuid: {locator: str, dispute_txid: str, ...}, uuid: {locator:...}}``
|
||||
|
||||
Returns:
|
||||
:obj:`tuple`: A tuple with two dictionaries. ``trackers`` containing the trackers' information in
|
||||
:obj:`TransactionTracker <teos.responder.TransactionTracker>` objects and a ``tx_tracker_map`` containing
|
||||
the map of trackers (``penalty_txid: uuid``).
|
||||
|
||||
"""
|
||||
|
||||
trackers = {}
|
||||
tx_tracker_map = {}
|
||||
|
||||
for uuid, data in tracker_data.items():
|
||||
trackers[uuid] = {
|
||||
"penalty_txid": data.get("penalty_txid"),
|
||||
"locator": data.get("locator"),
|
||||
"appointment_end": data.get("appointment_end"),
|
||||
}
|
||||
|
||||
if data.get("penalty_txid") in tx_tracker_map:
|
||||
tx_tracker_map[data.get("penalty_txid")].append(uuid)
|
||||
|
||||
else:
|
||||
tx_tracker_map[data.get("penalty_txid")] = [uuid]
|
||||
|
||||
return trackers, tx_tracker_map
|
||||
|
||||
@staticmethod
|
||||
def populate_block_queue(block_queue, missed_blocks):
|
||||
"""
|
||||
Populates a ``Queue`` of block hashes to initialize the :mod:`Watcher <teos.watcher.Watcher>` or the
|
||||
:mod:`Responder <teos.responder.Responder>` using backed up data.
|
||||
|
||||
Args:
|
||||
block_queue (:obj:`Queue`): a ``Queue``
|
||||
missed_blocks (:obj:`list`): list of block hashes missed by the Watchtower (do to a crash or shutdown).
|
||||
|
||||
Returns:
|
||||
:obj:`Queue`: A ``Queue`` containing all the missed blocks hashes.
|
||||
"""
|
||||
|
||||
for block in missed_blocks:
|
||||
block_queue.put(block)
|
||||
|
||||
@staticmethod
|
||||
def update_states(watcher, missed_blocks_watcher, missed_blocks_responder):
|
||||
"""
|
||||
Updates the states of both the :mod:`Watcher <teos.watcher.Watcher>` and the :mod:`Responder <teos.responder.Responder>`.
|
||||
If both have pending blocks to process they need to be updates at the same time, block by block.
|
||||
|
||||
If only one instance has to be updated, ``populate_block_queue`` should be used.
|
||||
|
||||
Args:
|
||||
watcher (:obj:`Watcher <teos.watcher.Watcher>`): a ``Watcher`` instance (including a ``Responder``).
|
||||
missed_blocks_watcher (:obj:`list`): the list of block missed by the ``Watcher``.
|
||||
missed_blocks_responder (:obj:`list`): the list of block missed by the ``Responder``.
|
||||
|
||||
Raises:
|
||||
ValueError: is one of the provided list is empty.
|
||||
"""
|
||||
|
||||
if len(missed_blocks_responder) == 0 or len(missed_blocks_watcher) == 0:
|
||||
raise ValueError(
|
||||
"Both the Watcher and the Responder must have missed blocks. Use ``populate_block_queue`` otherwise."
|
||||
)
|
||||
|
||||
# If the missed blocks of the Watcher and the Responder are not the same, we need to bring one up to date with
|
||||
# the other.
|
||||
if len(missed_blocks_responder) > len(missed_blocks_watcher):
|
||||
block_diff = sorted(
|
||||
set(missed_blocks_responder).difference(missed_blocks_watcher), key=missed_blocks_responder.index
|
||||
)
|
||||
Builder.populate_block_queue(watcher.responder.block_queue, block_diff)
|
||||
watcher.responder.block_queue.join()
|
||||
|
||||
elif len(missed_blocks_watcher) > len(missed_blocks_responder):
|
||||
block_diff = sorted(
|
||||
set(missed_blocks_watcher).difference(missed_blocks_responder), key=missed_blocks_watcher.index
|
||||
)
|
||||
Builder.populate_block_queue(watcher.block_queue, block_diff)
|
||||
watcher.block_queue.join()
|
||||
|
||||
# Once they are at the same height, we update them one by one
|
||||
for block in missed_blocks_watcher:
|
||||
watcher.block_queue.put(block)
|
||||
watcher.block_queue.join()
|
||||
|
||||
watcher.responder.block_queue.put(block)
|
||||
watcher.responder.block_queue.join()
|
||||
151
teos/carrier.py
Normal file
151
teos/carrier.py
Normal file
@@ -0,0 +1,151 @@
|
||||
from teos import LOG_PREFIX
|
||||
from teos.rpc_errors import *
|
||||
from common.logger import Logger
|
||||
from teos.tools import bitcoin_cli
|
||||
from teos.utils.auth_proxy import JSONRPCException
|
||||
from teos.errors import UNKNOWN_JSON_RPC_EXCEPTION, RPC_TX_REORGED_AFTER_BROADCAST
|
||||
|
||||
logger = Logger(actor="Carrier", log_name_prefix=LOG_PREFIX)
|
||||
|
||||
# FIXME: This class is not fully covered by unit tests
|
||||
|
||||
|
||||
class Receipt:
|
||||
"""
|
||||
The :class:`Receipt` class represent the interaction between the :obj:`Carrier` and ``bitcoind`` when broadcasting
|
||||
transactions. It is used to signal whether or not a transaction has been successfully broadcast and why.
|
||||
|
||||
Args:
|
||||
delivered (:obj:`bool`): whether or not the transaction has been successfully broadcast.
|
||||
confirmations (:obj:`int`): the number of confirmations of the transaction to broadcast. In certain situations
|
||||
the :obj:`Carrier` may fail to broadcast a transaction because it was already in the blockchain.
|
||||
This attribute signals those situations.
|
||||
reason (:obj:`int`): an error code describing why the transaction broadcast failed.
|
||||
|
||||
Returns:
|
||||
:obj:`Receipt`: A receipt describing whether or not the transaction was delivered. Notice that transactions
|
||||
that are already on chain are flagged as delivered with a ``confirmations > 0`` whereas new transactions are so
|
||||
with ``confirmations = 0``.
|
||||
"""
|
||||
|
||||
def __init__(self, delivered, confirmations=0, reason=None):
|
||||
self.delivered = delivered
|
||||
self.confirmations = confirmations
|
||||
self.reason = reason
|
||||
|
||||
|
||||
class Carrier:
|
||||
"""
|
||||
The :class:`Carrier` is the class in charge of interacting with ``bitcoind`` to send/get transactions. It uses
|
||||
:obj:`Receipt` objects to report about the sending outcome.
|
||||
|
||||
Attributes:
|
||||
issued_receipts (:obj:`dict`): a dictionary of issued receipts to prevent resending the same transaction over
|
||||
and over. It should periodically be reset to prevent it from growing unbounded.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.issued_receipts = {}
|
||||
|
||||
# NOTCOVERED
|
||||
def send_transaction(self, rawtx, txid):
|
||||
"""
|
||||
Tries to send a given raw transaction to the Bitcoin network using ``bitcoind``.
|
||||
|
||||
Args:
|
||||
rawtx (:obj:`str`): a (potentially) signed raw transaction ready to be broadcast.
|
||||
txid (:obj:`str`): the transaction id corresponding to ``rawtx``.
|
||||
|
||||
Returns:
|
||||
:obj:`Receipt`: A receipt reporting whether the transaction was successfully delivered or not and why.
|
||||
"""
|
||||
|
||||
if txid in self.issued_receipts:
|
||||
logger.info("Transaction already sent", txid=txid)
|
||||
receipt = self.issued_receipts[txid]
|
||||
|
||||
return receipt
|
||||
|
||||
try:
|
||||
logger.info("Pushing transaction to the network", txid=txid, rawtx=rawtx)
|
||||
bitcoin_cli().sendrawtransaction(rawtx)
|
||||
|
||||
receipt = Receipt(delivered=True)
|
||||
|
||||
except JSONRPCException as e:
|
||||
errno = e.error.get("code")
|
||||
# Since we're pushing a raw transaction to the network we can face several rejections
|
||||
if errno == RPC_VERIFY_REJECTED:
|
||||
# DISCUSS: 37-transaction-rejection
|
||||
receipt = Receipt(delivered=False, reason=RPC_VERIFY_REJECTED)
|
||||
logger.error("Transaction couldn't be broadcast", error=e.error)
|
||||
|
||||
elif errno == RPC_VERIFY_ERROR:
|
||||
# DISCUSS: 37-transaction-rejection
|
||||
receipt = Receipt(delivered=False, reason=RPC_VERIFY_ERROR)
|
||||
logger.error("Transaction couldn't be broadcast", error=e.error)
|
||||
|
||||
elif errno == RPC_VERIFY_ALREADY_IN_CHAIN:
|
||||
logger.info("Transaction is already in the blockchain. Getting confirmation count", txid=txid)
|
||||
|
||||
# If the transaction is already in the chain, we get the number of confirmations and watch the tracker
|
||||
# until the end of the appointment
|
||||
tx_info = self.get_transaction(txid)
|
||||
|
||||
if tx_info is not None:
|
||||
confirmations = int(tx_info.get("confirmations"))
|
||||
receipt = Receipt(delivered=True, confirmations=confirmations, reason=RPC_VERIFY_ALREADY_IN_CHAIN)
|
||||
|
||||
else:
|
||||
# There's a really unlikely edge case where a transaction can be reorged between receiving the
|
||||
# notification and querying the data. Notice that this implies the tx being also kicked off the
|
||||
# mempool, which again is really unlikely.
|
||||
receipt = Receipt(delivered=False, reason=RPC_TX_REORGED_AFTER_BROADCAST)
|
||||
|
||||
elif errno == RPC_DESERIALIZATION_ERROR:
|
||||
# Adding this here just for completeness. We should never end up here. The Carrier only sends txs
|
||||
# handed by the Responder, who receives them from the Watcher, who checks that the tx can be properly
|
||||
# deserialized
|
||||
logger.info("Transaction cannot be deserialized".format(txid))
|
||||
receipt = Receipt(delivered=False, reason=RPC_DESERIALIZATION_ERROR)
|
||||
|
||||
else:
|
||||
# If something else happens (unlikely but possible) log it so we can treat it in future releases
|
||||
logger.error("JSONRPCException", method="Carrier.send_transaction", error=e.error)
|
||||
receipt = Receipt(delivered=False, reason=UNKNOWN_JSON_RPC_EXCEPTION)
|
||||
|
||||
self.issued_receipts[txid] = receipt
|
||||
|
||||
return receipt
|
||||
|
||||
@staticmethod
|
||||
def get_transaction(txid):
|
||||
"""
|
||||
Queries transaction data to ``bitcoind`` given a transaction id.
|
||||
|
||||
Args:
|
||||
txid (:obj:`str`): a 32-byte hex-formatted string representing the transaction id.
|
||||
|
||||
Returns:
|
||||
:obj:`dict` or :obj:`None`: A dictionary with the transaction data if the transaction can be found on the
|
||||
chain.
|
||||
Returns ``None`` otherwise.
|
||||
"""
|
||||
|
||||
try:
|
||||
tx_info = bitcoin_cli().getrawtransaction(txid, 1)
|
||||
|
||||
except JSONRPCException as e:
|
||||
tx_info = None
|
||||
# While it's quite unlikely, the transaction that was already in the blockchain could have been
|
||||
# reorged while we were querying bitcoind to get the confirmation count. In such a case we just
|
||||
# restart the tracker
|
||||
if e.error.get("code") == RPC_INVALID_ADDRESS_OR_KEY:
|
||||
logger.info("Transaction not found in mempool nor blockchain", txid=txid)
|
||||
|
||||
else:
|
||||
# If something else happens (unlikely but possible) log it so we can treat it in future releases
|
||||
logger.error("JSONRPCException", method="Carrier.get_transaction", error=e.error)
|
||||
|
||||
return tx_info
|
||||
153
teos/chain_monitor.py
Normal file
153
teos/chain_monitor.py
Normal file
@@ -0,0 +1,153 @@
|
||||
import zmq
|
||||
import binascii
|
||||
from threading import Thread, Event, Condition
|
||||
|
||||
from teos import LOG_PREFIX
|
||||
from common.logger import Logger
|
||||
from teos.conf import FEED_PROTOCOL, FEED_ADDR, FEED_PORT, POLLING_DELTA, BLOCK_WINDOW_SIZE
|
||||
from teos.block_processor import BlockProcessor
|
||||
|
||||
logger = Logger(actor="ChainMonitor", log_name_prefix=LOG_PREFIX)
|
||||
|
||||
|
||||
class ChainMonitor:
|
||||
"""
|
||||
The :class:`ChainMonitor` is the class in charge of monitoring the blockchain (via ``bitcoind``) to detect new
|
||||
blocks on top of the best chain. If a new best block is spotted, the chain monitor will notify the
|
||||
:obj:`Watcher <teos.watcher.Watcher>` and the :obj:`Responder <teos.responder.Responder>` using ``Queues``.
|
||||
|
||||
The :class:`ChainMonitor` monitors the chain using two methods: ``zmq`` and ``polling``. Blocks are only notified
|
||||
once per queue and the notification is triggered by the method that detects the block faster.
|
||||
|
||||
Args:
|
||||
watcher_queue (:obj:`Queue`): the queue to be used to send blocks hashes to the ``Watcher``.
|
||||
responder_queue (:obj:`Queue`): the queue to be used to send blocks hashes to the ``Responder``.
|
||||
|
||||
Attributes:
|
||||
best_tip (:obj:`str`): a block hash representing the current best tip.
|
||||
last_tips (:obj:`list`): a list of last chain tips. Used as a sliding window to avoid notifying about old tips.
|
||||
terminate (:obj:`bool`): a flag to signal the termination of the :class:`ChainMonitor` (shutdown the tower).
|
||||
check_tip (:obj:`Event`): an event that's triggered at fixed time intervals and controls the polling thread.
|
||||
lock (:obj:`Condition`): a lock used to protect concurrent access to the queues and ``best_tip`` by the zmq and
|
||||
polling threads.
|
||||
zmqSubSocket (:obj:`socket`): a socket to connect to ``bitcoind`` via ``zmq``.
|
||||
watcher_queue (:obj:`Queue`): a queue to send new best tips to the :obj:`Watcher <teos.watcher.Watcher>`.
|
||||
responder_queue (:obj:`Queue`): a queue to send new best tips to the
|
||||
:obj:`Responder <teos.responder.Responder>`.
|
||||
"""
|
||||
|
||||
def __init__(self, watcher_queue, responder_queue):
|
||||
self.best_tip = None
|
||||
self.last_tips = []
|
||||
self.terminate = False
|
||||
|
||||
self.check_tip = Event()
|
||||
self.lock = Condition()
|
||||
|
||||
self.zmqContext = zmq.Context()
|
||||
self.zmqSubSocket = self.zmqContext.socket(zmq.SUB)
|
||||
self.zmqSubSocket.setsockopt(zmq.RCVHWM, 0)
|
||||
self.zmqSubSocket.setsockopt_string(zmq.SUBSCRIBE, "hashblock")
|
||||
self.zmqSubSocket.connect("%s://%s:%s" % (FEED_PROTOCOL, FEED_ADDR, FEED_PORT))
|
||||
|
||||
self.watcher_queue = watcher_queue
|
||||
self.responder_queue = responder_queue
|
||||
|
||||
def notify_subscribers(self, block_hash):
|
||||
"""
|
||||
Notifies the subscribers (``Watcher`` and ``Responder``) about a new block. It does so by putting the hash in
|
||||
the corresponding queue(s).
|
||||
|
||||
Args:
|
||||
block_hash (:obj:`str`): the new block hash to be sent to the subscribers.
|
||||
block_hash (:obj:`str`): the new block hash to be sent to the subscribers.
|
||||
"""
|
||||
|
||||
self.watcher_queue.put(block_hash)
|
||||
self.responder_queue.put(block_hash)
|
||||
|
||||
def update_state(self, block_hash, max_block_window_size=BLOCK_WINDOW_SIZE):
|
||||
"""
|
||||
Updates the state of the ``ChainMonitor``. The state is represented as the ``best_tip`` and the list of
|
||||
``last_tips``. ``last_tips`` is bounded to ``max_block_window_size``.
|
||||
|
||||
Args:
|
||||
block_hash (:obj:`block_hash`): the new best tip.
|
||||
max_block_window_size (:obj:`int`): the maximum length of the ``last_tips`` list.
|
||||
|
||||
Returns:
|
||||
(:obj:`bool`): ``True`` is the state was successfully updated, ``False`` otherwise.
|
||||
"""
|
||||
|
||||
if block_hash != self.best_tip and block_hash not in self.last_tips:
|
||||
self.last_tips.append(self.best_tip)
|
||||
self.best_tip = block_hash
|
||||
|
||||
if len(self.last_tips) > max_block_window_size:
|
||||
self.last_tips.pop(0)
|
||||
|
||||
return True
|
||||
|
||||
else:
|
||||
return False
|
||||
|
||||
def monitor_chain_polling(self, polling_delta=POLLING_DELTA):
|
||||
"""
|
||||
Monitors ``bitcoind`` via polling. Once the method is fired, it keeps monitoring as long as ``terminate`` is not
|
||||
set. Polling is performed once every ``polling_delta`` seconds. If a new best tip if found, the shared lock is
|
||||
acquired, the state is updated and the subscribers are notified, and finally the lock is released.
|
||||
|
||||
Args:
|
||||
polling_delta (:obj:`int`): the time delta between polls.
|
||||
"""
|
||||
|
||||
while not self.terminate:
|
||||
self.check_tip.wait(timeout=polling_delta)
|
||||
|
||||
# Terminate could have been set while the thread was blocked in wait
|
||||
if not self.terminate:
|
||||
current_tip = BlockProcessor.get_best_block_hash()
|
||||
|
||||
self.lock.acquire()
|
||||
if self.update_state(current_tip):
|
||||
self.notify_subscribers(current_tip)
|
||||
logger.info("New block received via polling", block_hash=current_tip)
|
||||
self.lock.release()
|
||||
|
||||
def monitor_chain_zmq(self):
|
||||
"""
|
||||
Monitors ``bitcoind`` via zmq. Once the method is fired, it keeps monitoring as long as ``terminate`` is not
|
||||
set. If a new best tip if found, the shared lock is acquired, the state is updated and the subscribers are
|
||||
notified, and finally the lock is released.
|
||||
"""
|
||||
|
||||
while not self.terminate:
|
||||
msg = self.zmqSubSocket.recv_multipart()
|
||||
|
||||
# Terminate could have been set while the thread was blocked in recv
|
||||
if not self.terminate:
|
||||
topic = msg[0]
|
||||
body = msg[1]
|
||||
|
||||
if topic == b"hashblock":
|
||||
block_hash = binascii.hexlify(body).decode("utf-8")
|
||||
|
||||
self.lock.acquire()
|
||||
if self.update_state(block_hash):
|
||||
self.notify_subscribers(block_hash)
|
||||
logger.info("New block received via zmq", block_hash=block_hash)
|
||||
self.lock.release()
|
||||
|
||||
def monitor_chain(self, polling_delta=POLLING_DELTA):
|
||||
"""
|
||||
Main :class:`ChainMonitor` method. It initializes the ``best_tip`` to the current one (by querying the
|
||||
:obj:`BlockProcessor <teos.block_processor.BlockProcessor>`) and creates two threads, one per each monitoring
|
||||
approach (``zmq`` and ``polling``).
|
||||
|
||||
Args:
|
||||
polling_delta (:obj:`int`): the time delta between polls by the ``monitor_chain_polling`` thread.
|
||||
"""
|
||||
|
||||
self.best_tip = BlockProcessor.get_best_block_hash()
|
||||
Thread(target=self.monitor_chain_polling, daemon=True, kwargs={"polling_delta": polling_delta}).start()
|
||||
Thread(target=self.monitor_chain_zmq, daemon=True).start()
|
||||
231
teos/cleaner.py
Normal file
231
teos/cleaner.py
Normal file
@@ -0,0 +1,231 @@
|
||||
from teos import LOG_PREFIX
|
||||
|
||||
from common.logger import Logger
|
||||
|
||||
logger = Logger(actor="Cleaner", log_name_prefix=LOG_PREFIX)
|
||||
|
||||
|
||||
class Cleaner:
|
||||
"""
|
||||
The :class:`Cleaner` is the class in charge of removing expired/completed data from the tower.
|
||||
|
||||
Mutable objects (like dicts) are passed-by-reference in Python, so no return is needed for the Cleaner.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def delete_appointment_from_memory(uuid, appointments, locator_uuid_map):
|
||||
"""
|
||||
Deletes an appointment from memory (appointments and locator_uuid_map dictionaries). If the given appointment
|
||||
does not share locator with any other, the map will completely removed, otherwise, the uuid will be removed from
|
||||
the map.
|
||||
|
||||
Args:
|
||||
uuid (:obj:`str`): the identifier of the appointment to be deleted.
|
||||
appointments (:obj:`dict`): the appointments dictionary from where the appointment should be removed.
|
||||
locator_uuid_map (:obj:`dict`): the locator:uuid map from where the appointment should also be removed.
|
||||
"""
|
||||
locator = appointments[uuid].get("locator")
|
||||
|
||||
# Delete the appointment
|
||||
appointments.pop(uuid)
|
||||
|
||||
# If there was only one appointment that matches the locator we can delete the whole list
|
||||
if len(locator_uuid_map[locator]) == 1:
|
||||
locator_uuid_map.pop(locator)
|
||||
else:
|
||||
# Otherwise we just delete the appointment that matches locator:appointment_pos
|
||||
locator_uuid_map[locator].remove(uuid)
|
||||
|
||||
@staticmethod
|
||||
def delete_appointment_from_db(uuid, db_manager):
|
||||
"""
|
||||
Deletes an appointment from the appointments database.
|
||||
|
||||
Args:
|
||||
uuid (:obj:`str`): the identifier of the appointment to be deleted.
|
||||
db_manager (:obj:`DBManager <teos.db_manager.DBManager>`): a ``DBManager`` instance to interact with the
|
||||
database.
|
||||
"""
|
||||
|
||||
db_manager.delete_watcher_appointment(uuid)
|
||||
db_manager.delete_triggered_appointment_flag(uuid)
|
||||
|
||||
@staticmethod
|
||||
def update_delete_db_locator_map(uuids, locator, db_manager):
|
||||
"""
|
||||
Updates the locator:uuid map of a given locator from the database by removing a given uuid. If the uuid is the
|
||||
only element of the map, the map is deleted, otherwise the uuid is simply removed and the database is updated.
|
||||
|
||||
If either the uuid of the locator are not found, the data is not modified.
|
||||
|
||||
Args:
|
||||
uuids (:obj:`list`): a list of identifiers to be removed from the map.
|
||||
locator (:obj:`str`): the identifier of the map to be either updated or deleted.
|
||||
db_manager (:obj:`DBManager <teos.db_manager.DBManager>`): a ``DBManager`` instance to interact with the
|
||||
database.
|
||||
"""
|
||||
|
||||
locator_map = db_manager.load_locator_map(locator)
|
||||
|
||||
if locator_map is not None:
|
||||
if set(locator_map).issuperset(uuids):
|
||||
# Remove the map if all keys are requested to be deleted
|
||||
if set(locator_map) == set(uuids):
|
||||
db_manager.delete_locator_map(locator)
|
||||
else:
|
||||
# Otherwise remove only the selected keys
|
||||
locator_map = list(set(locator_map).difference(uuids))
|
||||
db_manager.update_locator_map(locator, locator_map)
|
||||
|
||||
else:
|
||||
logger.error("Some UUIDs not found in the db", locator=locator, all_uuids=uuids)
|
||||
|
||||
else:
|
||||
logger.error("Locator map not found in the db", uuid=locator)
|
||||
|
||||
@staticmethod
|
||||
def delete_expired_appointments(expired_appointments, appointments, locator_uuid_map, db_manager):
|
||||
"""
|
||||
Deletes appointments which ``end_time`` has been reached (with no trigger) both from memory
|
||||
(:obj:`Watcher <teos.watcher.Watcher>`) and disk.
|
||||
|
||||
Args:
|
||||
expired_appointments (:obj:`list`): a list of appointments to be deleted.
|
||||
appointments (:obj:`dict`): a dictionary containing all the :mod:`Watcher <teos.watcher.Watcher>`
|
||||
appointments.
|
||||
locator_uuid_map (:obj:`dict`): a ``locator:uuid`` map for the :obj:`Watcher <teos.watcher.Watcher>`
|
||||
appointments.
|
||||
db_manager (:obj:`DBManager <teos.db_manager.DBManager>`): a ``DBManager`` instance to interact with the
|
||||
database.
|
||||
"""
|
||||
|
||||
locator_maps_to_update = {}
|
||||
|
||||
for uuid in expired_appointments:
|
||||
locator = appointments[uuid].get("locator")
|
||||
logger.info("End time reached with no breach. Deleting appointment", locator=locator, uuid=uuid)
|
||||
|
||||
Cleaner.delete_appointment_from_memory(uuid, appointments, locator_uuid_map)
|
||||
|
||||
if locator not in locator_maps_to_update:
|
||||
locator_maps_to_update[locator] = []
|
||||
|
||||
locator_maps_to_update[locator].append(uuid)
|
||||
|
||||
for locator, uuids in locator_maps_to_update.items():
|
||||
Cleaner.update_delete_db_locator_map(uuids, locator, db_manager)
|
||||
|
||||
# Expired appointments are not flagged, so they can be deleted without caring about the db flag.
|
||||
db_manager.batch_delete_watcher_appointments(expired_appointments)
|
||||
|
||||
@staticmethod
|
||||
def delete_completed_appointments(completed_appointments, appointments, locator_uuid_map, db_manager):
|
||||
"""
|
||||
Deletes a completed appointment from memory (:obj:`Watcher <teos.watcher.Watcher>`) and disk.
|
||||
|
||||
Currently, an appointment is only completed if it cannot make it to the (:obj:`Responder <teos.responder.Responder>`),
|
||||
otherwise, it will be flagged as triggered and removed once the tracker is completed.
|
||||
|
||||
Args:
|
||||
completed_appointments (:obj:`list`): a list of appointments to be deleted.
|
||||
appointments (:obj:`dict`): a dictionary containing all the :obj:`Watcher <teos.watcher.Watcher>`
|
||||
appointments.
|
||||
locator_uuid_map (:obj:`dict`): a ``locator:uuid`` map for the :obj:`Watcher <teos.watcher.Watcher>`
|
||||
appointments.
|
||||
db_manager (:obj:`DBManager <teos.db_manager.DBManager>`): a ``DBManager`` instance to interact with the
|
||||
database.
|
||||
"""
|
||||
locator_maps_to_update = {}
|
||||
|
||||
for uuid in completed_appointments:
|
||||
locator = appointments[uuid].get("locator")
|
||||
|
||||
logger.warning(
|
||||
"Appointment cannot be completed, it contains invalid data. Deleting", locator=locator, uuid=uuid
|
||||
)
|
||||
|
||||
Cleaner.delete_appointment_from_memory(uuid, appointments, locator_uuid_map)
|
||||
|
||||
if locator not in locator_maps_to_update:
|
||||
locator_maps_to_update[locator] = []
|
||||
|
||||
locator_maps_to_update[locator].append(uuid)
|
||||
|
||||
for locator, uuids in locator_maps_to_update.items():
|
||||
# Update / delete the locator map
|
||||
Cleaner.update_delete_db_locator_map(uuids, locator, db_manager)
|
||||
|
||||
db_manager.batch_delete_watcher_appointments(completed_appointments)
|
||||
|
||||
@staticmethod
|
||||
def flag_triggered_appointments(triggered_appointments, appointments, locator_uuid_map, db_manager):
|
||||
"""
|
||||
Deletes a list of triggered appointment from memory (:obj:`Watcher <teos.watcher.Watcher>`) and flags them as
|
||||
triggered on disk.
|
||||
|
||||
Args:
|
||||
triggered_appointments (:obj:`list`): a list of appointments to be flagged as triggered on the database.
|
||||
appointments (:obj:`dict`): a dictionary containing all the :obj:`Watcher <teos.watcher.Watcher>`
|
||||
appointments.
|
||||
locator_uuid_map (:obj:`dict`): a ``locator:uuid`` map for the :obj:`Watcher <teos.watcher.Watcher>`
|
||||
appointments.
|
||||
db_manager (:obj:`DBManager <teos.db_manager.DBManager>`): a ``DBManager`` instance to interact with the
|
||||
database.
|
||||
"""
|
||||
|
||||
for uuid in triggered_appointments:
|
||||
Cleaner.delete_appointment_from_memory(uuid, appointments, locator_uuid_map)
|
||||
db_manager.create_triggered_appointment_flag(uuid)
|
||||
|
||||
@staticmethod
|
||||
def delete_completed_trackers(completed_trackers, height, trackers, tx_tracker_map, db_manager):
|
||||
"""
|
||||
Deletes a completed tracker both from memory (:obj:`Responder <teos.responder.Responder>`) and disk (from the
|
||||
Responder's and Watcher's databases).
|
||||
|
||||
Args:
|
||||
trackers (:obj:`dict`): a dictionary containing all the :obj:`Responder <teos.responder.Responder>`
|
||||
trackers.
|
||||
tx_tracker_map (:obj:`dict`): a ``penalty_txid:uuid`` map for the :obj:`Responder
|
||||
<teos.responder.Responder>` trackers.
|
||||
completed_trackers (:obj:`dict`): a dict of completed trackers to be deleted (uuid:confirmations).
|
||||
height (:obj:`int`): the block height at which the trackers were completed.
|
||||
db_manager (:obj:`DBManager <teos.db_manager.DBManager>`): a ``DBManager`` instance to interact with the
|
||||
database.
|
||||
"""
|
||||
|
||||
locator_maps_to_update = {}
|
||||
|
||||
for uuid, confirmations in completed_trackers.items():
|
||||
logger.info(
|
||||
"Appointment completed. Appointment ended after reaching enough confirmations",
|
||||
uuid=uuid,
|
||||
height=height,
|
||||
confirmations=confirmations,
|
||||
)
|
||||
|
||||
penalty_txid = trackers[uuid].get("penalty_txid")
|
||||
locator = trackers[uuid].get("locator")
|
||||
trackers.pop(uuid)
|
||||
|
||||
if len(tx_tracker_map[penalty_txid]) == 1:
|
||||
tx_tracker_map.pop(penalty_txid)
|
||||
|
||||
logger.info("No more trackers for penalty transaction", penalty_txid=penalty_txid)
|
||||
|
||||
else:
|
||||
tx_tracker_map[penalty_txid].remove(uuid)
|
||||
|
||||
if locator not in locator_maps_to_update:
|
||||
locator_maps_to_update[locator] = []
|
||||
|
||||
locator_maps_to_update[locator].append(uuid)
|
||||
|
||||
for locator, uuids in locator_maps_to_update.items():
|
||||
# Update / delete the locator map
|
||||
Cleaner.update_delete_db_locator_map(uuids, locator, db_manager)
|
||||
|
||||
# Delete appointment from the db (from watchers's and responder's db) and remove flag
|
||||
db_manager.batch_delete_responder_trackers(list(completed_trackers.keys()))
|
||||
db_manager.batch_delete_watcher_appointments(list(completed_trackers.keys()))
|
||||
db_manager.batch_delete_triggered_appointment_flag(list(completed_trackers.keys()))
|
||||
459
teos/db_manager.py
Normal file
459
teos/db_manager.py
Normal file
@@ -0,0 +1,459 @@
|
||||
import json
|
||||
import plyvel
|
||||
|
||||
from teos import LOG_PREFIX
|
||||
|
||||
from common.logger import Logger
|
||||
|
||||
logger = Logger(actor="DBManager", log_name_prefix=LOG_PREFIX)
|
||||
|
||||
WATCHER_PREFIX = "w"
|
||||
WATCHER_LAST_BLOCK_KEY = "bw"
|
||||
RESPONDER_PREFIX = "r"
|
||||
RESPONDER_LAST_BLOCK_KEY = "br"
|
||||
LOCATOR_MAP_PREFIX = "m"
|
||||
TRIGGERED_APPOINTMENTS_PREFIX = "ta"
|
||||
|
||||
|
||||
class DBManager:
|
||||
"""
|
||||
The :class:`DBManager` is the class in charge of interacting with the appointments database (``LevelDB``).
|
||||
Keys and values are stored as bytes in the database but processed as strings by the manager.
|
||||
|
||||
The database is split in six prefixes:
|
||||
|
||||
- ``WATCHER_PREFIX``, defined as ``b'w``, is used to store :obj:`Watcher <teos.watcher.Watcher>` appointments.
|
||||
- ``RESPONDER_PREFIX``, defines as ``b'r``, is used to store :obj:`Responder <teos.responder.Responder>` trackers.
|
||||
- ``WATCHER_LAST_BLOCK_KEY``, defined as ``b'bw``, is used to store the last block hash known by the :obj:`Watcher <teos.watcher.Watcher>`.
|
||||
- ``RESPONDER_LAST_BLOCK_KEY``, defined as ``b'br``, is used to store the last block hash known by the :obj:`Responder <teos.responder.Responder>`.
|
||||
- ``LOCATOR_MAP_PREFIX``, defined as ``b'm``, is used to store the ``locator:uuid`` maps.
|
||||
- ``TRIGGERED_APPOINTMENTS_PREFIX``, defined as ``b'ta``, is used to stored triggered appointments (appointments that have been handed to the :obj:`Responder <teos.responder.Responder>`.)
|
||||
|
||||
Args:
|
||||
db_path (:obj:`str`): the path (relative or absolute) to the system folder containing the database. A fresh
|
||||
database will be create if the specified path does not contain one.
|
||||
|
||||
Raises:
|
||||
ValueError: If the provided ``db_path`` is not a string.
|
||||
plyvel.Error: If the db is currently unavailable (being used by another process).
|
||||
"""
|
||||
|
||||
def __init__(self, db_path):
|
||||
if not isinstance(db_path, str):
|
||||
raise ValueError("db_path must be a valid path/name")
|
||||
|
||||
try:
|
||||
self.db = plyvel.DB(db_path)
|
||||
|
||||
except plyvel.Error as e:
|
||||
if "create_if_missing is false" in str(e):
|
||||
logger.info("No db found. Creating a fresh one")
|
||||
self.db = plyvel.DB(db_path, create_if_missing=True)
|
||||
|
||||
elif "LOCK: Resource temporarily unavailable" in str(e):
|
||||
logger.info("The db is already being used by another process (LOCK)")
|
||||
raise e
|
||||
|
||||
def load_appointments_db(self, prefix):
|
||||
"""
|
||||
Loads all data from the appointments database given a prefix. Two prefixes are defined: ``WATCHER_PREFIX`` and
|
||||
``RESPONDER_PREFIX``.
|
||||
|
||||
Args:
|
||||
prefix (:obj:`str`): the prefix of the data to load.
|
||||
|
||||
Returns:
|
||||
:obj:`dict`: A dictionary containing the requested data (appointments or trackers) indexed by ``uuid``.
|
||||
|
||||
Returns an empty dictionary if no data is found.
|
||||
"""
|
||||
|
||||
data = {}
|
||||
|
||||
for k, v in self.db.iterator(prefix=prefix.encode("utf-8")):
|
||||
# Get uuid and appointment_data from the db
|
||||
uuid = k[len(prefix) :].decode("utf-8")
|
||||
data[uuid] = json.loads(v)
|
||||
|
||||
return data
|
||||
|
||||
def get_last_known_block(self, key):
|
||||
"""
|
||||
Loads the last known block given a key (either ``WATCHER_LAST_BLOCK_KEY`` or ``RESPONDER_LAST_BLOCK_KEY``).
|
||||
|
||||
Returns:
|
||||
:obj:`str` or :obj:`None`: A 16-byte hex-encoded str representing the last known block hash.
|
||||
|
||||
Returns ``None`` if the entry is not found.
|
||||
"""
|
||||
|
||||
last_block = self.db.get(key.encode("utf-8"))
|
||||
|
||||
if last_block:
|
||||
last_block = last_block.decode("utf-8")
|
||||
|
||||
return last_block
|
||||
|
||||
def create_entry(self, key, value, prefix=None):
|
||||
"""
|
||||
Creates a new entry in the database.
|
||||
|
||||
Args:
|
||||
key (:obj:`str`): the key of the new entry, used to identify it.
|
||||
value (:obj:`str`): the data stored under the given ``key``.
|
||||
prefix (:obj:`str`): an optional prefix added to the ``key``.
|
||||
"""
|
||||
|
||||
if isinstance(prefix, str):
|
||||
key = prefix + key
|
||||
|
||||
key = key.encode("utf-8")
|
||||
value = value.encode("utf-8")
|
||||
|
||||
self.db.put(key, value)
|
||||
|
||||
def load_entry(self, key):
|
||||
"""
|
||||
Loads an entry from the database given a ``key``.
|
||||
|
||||
Args:
|
||||
key (:obj:`str`): the key that identifies the entry to be loaded.
|
||||
|
||||
Returns:
|
||||
:obj:`dict` or :obj:`None`: A dictionary containing the requested data (an appointment or a tracker).
|
||||
|
||||
Returns ``None`` if the entry is not found.
|
||||
"""
|
||||
|
||||
data = self.db.get(key.encode("utf-8"))
|
||||
data = json.loads(data) if data is not None else data
|
||||
return data
|
||||
|
||||
def delete_entry(self, key, prefix=None):
|
||||
"""
|
||||
Deletes an entry from the database given an ``key`` (and optionally a ``prefix``)
|
||||
|
||||
Args:
|
||||
key (:obj:`str`): the key that identifies the data to be deleted.
|
||||
prefix (:obj:`str`): an optional prefix to be prepended to the ``key``.
|
||||
"""
|
||||
|
||||
if isinstance(prefix, str):
|
||||
key = prefix + key
|
||||
|
||||
key = key.encode("utf-8")
|
||||
|
||||
self.db.delete(key)
|
||||
|
||||
def load_watcher_appointment(self, key):
|
||||
"""
|
||||
Loads an appointment from the database using ``WATCHER_PREFIX`` as prefix to the given ``key``.
|
||||
|
||||
Returns:
|
||||
:obj:`dict`: A dictionary containing the appointment data if they ``key`` is found.
|
||||
|
||||
Returns ``None`` otherwise.
|
||||
"""
|
||||
|
||||
return self.load_entry(WATCHER_PREFIX + key)
|
||||
|
||||
def load_responder_tracker(self, key):
|
||||
"""
|
||||
Loads a tracker from the database using ``RESPONDER_PREFIX`` as a prefix to the given ``key``.
|
||||
|
||||
Returns:
|
||||
:obj:`dict`: A dictionary containing the tracker data if they ``key`` is found.
|
||||
|
||||
Returns ``None`` otherwise.
|
||||
"""
|
||||
|
||||
return self.load_entry(RESPONDER_PREFIX + key)
|
||||
|
||||
def load_watcher_appointments(self, include_triggered=False):
|
||||
"""
|
||||
Loads all the appointments from the database (all entries with the ``WATCHER_PREFIX`` prefix).
|
||||
Args:
|
||||
include_triggered (:obj:`bool`): Whether to include the appointments flagged as triggered or not. ``False``
|
||||
by default.
|
||||
|
||||
Returns:
|
||||
:obj:`dict`: A dictionary with all the appointments stored in the database. An empty dictionary is there
|
||||
are none.
|
||||
"""
|
||||
|
||||
appointments = self.load_appointments_db(prefix=WATCHER_PREFIX)
|
||||
triggered_appointments = self.load_all_triggered_flags()
|
||||
|
||||
if not include_triggered:
|
||||
not_triggered = list(set(appointments.keys()).difference(triggered_appointments))
|
||||
appointments = {uuid: appointments[uuid] for uuid in not_triggered}
|
||||
|
||||
return appointments
|
||||
|
||||
def load_responder_trackers(self):
|
||||
"""
|
||||
Loads all the trackers from the database (all entries with the ``RESPONDER_PREFIX`` prefix).
|
||||
|
||||
Returns:
|
||||
:obj:`dict`: A dictionary with all the trackers stored in the database. An empty dictionary is there are
|
||||
none.
|
||||
"""
|
||||
|
||||
return self.load_appointments_db(prefix=RESPONDER_PREFIX)
|
||||
|
||||
def store_watcher_appointment(self, uuid, appointment):
|
||||
"""
|
||||
Stores an appointment in the database using the ``WATCHER_PREFIX`` prefix.
|
||||
|
||||
Args:
|
||||
uuid (:obj:`str`): the identifier of the appointment to be stored.
|
||||
appointment (:obj: `str`): the json encoded appointment to be stored as data.
|
||||
"""
|
||||
|
||||
self.create_entry(uuid, appointment, prefix=WATCHER_PREFIX)
|
||||
logger.info("Adding appointment to Watchers's db", uuid=uuid)
|
||||
|
||||
def store_responder_tracker(self, uuid, tracker):
|
||||
"""
|
||||
Stores a tracker in the database using the ``RESPONDER_PREFIX`` prefix.
|
||||
|
||||
Args:
|
||||
uuid (:obj:`str`): the identifier of the appointment to be stored.
|
||||
tracker (:obj: `str`): the json encoded tracker to be stored as data.
|
||||
"""
|
||||
|
||||
self.create_entry(uuid, tracker, prefix=RESPONDER_PREFIX)
|
||||
logger.info("Adding appointment to Responder's db", uuid=uuid)
|
||||
|
||||
def load_locator_map(self, locator):
|
||||
"""
|
||||
Loads the ``locator:uuid`` map of a given ``locator`` from the database.
|
||||
|
||||
Args:
|
||||
locator (:obj:`str`): a 16-byte hex-encoded string representing the appointment locator.
|
||||
|
||||
Returns:
|
||||
:obj:`dict` or :obj:`None`: The requested ``locator:uuid`` map if found.
|
||||
|
||||
Returns ``None`` otherwise.
|
||||
"""
|
||||
|
||||
key = (LOCATOR_MAP_PREFIX + locator).encode("utf-8")
|
||||
locator_map = self.db.get(key)
|
||||
|
||||
if locator_map is not None:
|
||||
locator_map = json.loads(locator_map.decode("utf-8"))
|
||||
|
||||
else:
|
||||
logger.info("Locator not found in the db", locator=locator)
|
||||
|
||||
return locator_map
|
||||
|
||||
def create_append_locator_map(self, locator, uuid):
|
||||
"""
|
||||
Creates (or appends to if already exists) a ``locator:uuid`` map.
|
||||
|
||||
If the map already exists, the new ``uuid`` is appended to the existing ones (if it is not already there).
|
||||
|
||||
Args:
|
||||
locator (:obj:`str`): a 16-byte hex-encoded string used as the key of the map.
|
||||
uuid (:obj:`str`): a 16-byte hex-encoded unique id to create (or add to) the map.
|
||||
"""
|
||||
|
||||
locator_map = self.load_locator_map(locator)
|
||||
|
||||
if locator_map is not None:
|
||||
if uuid not in locator_map:
|
||||
locator_map.append(uuid)
|
||||
logger.info("Updating locator map", locator=locator, uuid=uuid)
|
||||
|
||||
else:
|
||||
logger.info("UUID already in the map", locator=locator, uuid=uuid)
|
||||
|
||||
else:
|
||||
locator_map = [uuid]
|
||||
logger.info("Creating new locator map", locator=locator, uuid=uuid)
|
||||
|
||||
key = (LOCATOR_MAP_PREFIX + locator).encode("utf-8")
|
||||
self.db.put(key, json.dumps(locator_map).encode("utf-8"))
|
||||
|
||||
def update_locator_map(self, locator, locator_map):
|
||||
"""
|
||||
Updates a ``locator:uuid`` map in the database by deleting one of it's uuid. It will only work as long as
|
||||
the given ``locator_map`` is a subset of the current one and it's not empty.
|
||||
|
||||
Args:
|
||||
locator (:obj:`str`): a 16-byte hex-encoded string used as the key of the map.
|
||||
locator_map (:obj:`list`): a list of uuids to replace the current one on the db.
|
||||
"""
|
||||
|
||||
current_locator_map = self.load_locator_map(locator)
|
||||
|
||||
if set(locator_map).issubset(current_locator_map) and len(locator_map) is not 0:
|
||||
key = (LOCATOR_MAP_PREFIX + locator).encode("utf-8")
|
||||
self.db.put(key, json.dumps(locator_map).encode("utf-8"))
|
||||
|
||||
else:
|
||||
logger.error("Trying to update a locator_map with completely different, or empty, data")
|
||||
|
||||
def delete_locator_map(self, locator):
|
||||
"""
|
||||
Deletes a ``locator:uuid`` map.
|
||||
|
||||
Args:
|
||||
locator (:obj:`str`): a 16-byte hex-encoded string identifying the map to delete.
|
||||
"""
|
||||
|
||||
self.delete_entry(locator, prefix=LOCATOR_MAP_PREFIX)
|
||||
logger.info("Deleting locator map from db", uuid=locator)
|
||||
|
||||
def delete_watcher_appointment(self, uuid):
|
||||
"""
|
||||
Deletes an appointment from the database.
|
||||
|
||||
Args:
|
||||
uuid (:obj:`str`): a 16-byte hex-encoded string identifying the appointment to be deleted.
|
||||
"""
|
||||
|
||||
self.delete_entry(uuid, prefix=WATCHER_PREFIX)
|
||||
logger.info("Deleting appointment from Watcher's db", uuid=uuid)
|
||||
|
||||
def batch_delete_watcher_appointments(self, uuids):
|
||||
"""
|
||||
Deletes an appointment from the database.
|
||||
|
||||
Args:
|
||||
uuids (:obj:`list`): a list of 16-byte hex-encoded strings identifying the appointments to be deleted.
|
||||
"""
|
||||
|
||||
with self.db.write_batch() as b:
|
||||
for uuid in uuids:
|
||||
b.delete((WATCHER_PREFIX + uuid).encode("utf-8"))
|
||||
logger.info("Deleting appointment from Watcher's db", uuid=uuid)
|
||||
|
||||
def delete_responder_tracker(self, uuid):
|
||||
"""
|
||||
Deletes a tracker from the database.
|
||||
|
||||
Args:
|
||||
uuid (:obj:`str`): a 16-byte hex-encoded string identifying the tracker to be deleted.
|
||||
"""
|
||||
|
||||
self.delete_entry(uuid, prefix=RESPONDER_PREFIX)
|
||||
logger.info("Deleting appointment from Responder's db", uuid=uuid)
|
||||
|
||||
def batch_delete_responder_trackers(self, uuids):
|
||||
"""
|
||||
Deletes an appointment from the database.
|
||||
|
||||
Args:
|
||||
uuids (:obj:`list`): a list of 16-byte hex-encoded strings identifying the trackers to be deleted.
|
||||
"""
|
||||
|
||||
with self.db.write_batch() as b:
|
||||
for uuid in uuids:
|
||||
b.delete((RESPONDER_PREFIX + uuid).encode("utf-8"))
|
||||
logger.info("Deleting appointment from Responder's db", uuid=uuid)
|
||||
|
||||
def load_last_block_hash_watcher(self):
|
||||
"""
|
||||
Loads the last known block hash of the :obj:`Watcher <teos.watcher.Watcher>` from the database.
|
||||
|
||||
Returns:
|
||||
:obj:`str` or :obj:`None`: A 32-byte hex-encoded string representing the last known block hash if found.
|
||||
|
||||
Returns ``None`` otherwise.
|
||||
"""
|
||||
return self.get_last_known_block(WATCHER_LAST_BLOCK_KEY)
|
||||
|
||||
def load_last_block_hash_responder(self):
|
||||
"""
|
||||
Loads the last known block hash of the :obj:`Responder <teos.responder.Responder>` from the database.
|
||||
|
||||
Returns:
|
||||
:obj:`str` or :obj:`None`: A 32-byte hex-encoded string representing the last known block hash if found.
|
||||
|
||||
Returns ``None`` otherwise.
|
||||
"""
|
||||
return self.get_last_known_block(RESPONDER_LAST_BLOCK_KEY)
|
||||
|
||||
def store_last_block_hash_watcher(self, block_hash):
|
||||
"""
|
||||
Stores a block hash as the last known block of the :obj:`Watcher <teos.watcher.Watcher>`.
|
||||
|
||||
Args:
|
||||
block_hash (:obj:`str`): the block hash to be stored (32-byte hex-encoded)
|
||||
"""
|
||||
|
||||
self.create_entry(WATCHER_LAST_BLOCK_KEY, block_hash)
|
||||
|
||||
def store_last_block_hash_responder(self, block_hash):
|
||||
"""
|
||||
Stores a block hash as the last known block of the :obj:`Responder <teos.responder.Responder>`.
|
||||
|
||||
Args:
|
||||
block_hash (:obj:`str`): the block hash to be stored (32-byte hex-encoded)
|
||||
"""
|
||||
|
||||
self.create_entry(RESPONDER_LAST_BLOCK_KEY, block_hash)
|
||||
|
||||
def create_triggered_appointment_flag(self, uuid):
|
||||
"""
|
||||
Creates a flag that signals that an appointment has been triggered.
|
||||
|
||||
Args:
|
||||
uuid (:obj:`str`): the identifier of the flag to be created.
|
||||
"""
|
||||
|
||||
self.db.put((TRIGGERED_APPOINTMENTS_PREFIX + uuid).encode("utf-8"), "".encode("utf-8"))
|
||||
logger.info("Flagging appointment as triggered", uuid=uuid)
|
||||
|
||||
def batch_create_triggered_appointment_flag(self, uuids):
|
||||
"""
|
||||
Creates a flag that signals that an appointment has been triggered for every appointment in the given list
|
||||
|
||||
Args:
|
||||
uuids (:obj:`list`): a list of identifier for the appointments to flag.
|
||||
"""
|
||||
|
||||
with self.db.write_batch() as b:
|
||||
for uuid in uuids:
|
||||
b.put((TRIGGERED_APPOINTMENTS_PREFIX + uuid).encode("utf-8"), b"")
|
||||
logger.info("Flagging appointment as triggered", uuid=uuid)
|
||||
|
||||
def load_all_triggered_flags(self):
|
||||
"""
|
||||
Loads all the appointment triggered flags from the database.
|
||||
|
||||
Returns:
|
||||
:obj:`list`: a list of all the uuids of the triggered appointments.
|
||||
"""
|
||||
|
||||
return [
|
||||
k.decode()[len(TRIGGERED_APPOINTMENTS_PREFIX) :]
|
||||
for k, v in self.db.iterator(prefix=TRIGGERED_APPOINTMENTS_PREFIX.encode("utf-8"))
|
||||
]
|
||||
|
||||
def delete_triggered_appointment_flag(self, uuid):
|
||||
"""
|
||||
Deletes a flag that signals that an appointment has been triggered.
|
||||
|
||||
Args:
|
||||
uuid (:obj:`str`): the identifier of the flag to be removed.
|
||||
"""
|
||||
|
||||
self.delete_entry(uuid, prefix=TRIGGERED_APPOINTMENTS_PREFIX)
|
||||
logger.info("Removing triggered flag from appointment appointment", uuid=uuid)
|
||||
|
||||
def batch_delete_triggered_appointment_flag(self, uuids):
|
||||
"""
|
||||
Deletes a list of flag signaling that some appointment have been triggered.
|
||||
|
||||
Args:
|
||||
uuids (:obj:`list`): the identifier of the flag to be removed.
|
||||
"""
|
||||
|
||||
with self.db.write_batch() as b:
|
||||
for uuid in uuids:
|
||||
b.delete((TRIGGERED_APPOINTMENTS_PREFIX + uuid).encode("utf-8"))
|
||||
logger.info("Removing triggered flag from appointment appointment", uuid=uuid)
|
||||
15
teos/errors.py
Normal file
15
teos/errors.py
Normal file
@@ -0,0 +1,15 @@
|
||||
# Appointment errors
|
||||
APPOINTMENT_EMPTY_FIELD = -1
|
||||
APPOINTMENT_WRONG_FIELD_TYPE = -2
|
||||
APPOINTMENT_WRONG_FIELD_SIZE = -3
|
||||
APPOINTMENT_WRONG_FIELD_FORMAT = -4
|
||||
APPOINTMENT_FIELD_TOO_SMALL = -5
|
||||
APPOINTMENT_FIELD_TOO_BIG = -6
|
||||
APPOINTMENT_WRONG_FIELD = -7
|
||||
APPOINTMENT_INVALID_SIGNATURE = -8
|
||||
|
||||
# Custom RPC errors
|
||||
RPC_TX_REORGED_AFTER_BROADCAST = -98
|
||||
|
||||
# UNHANDLED
|
||||
UNKNOWN_JSON_RPC_EXCEPTION = -99
|
||||
384
teos/inspector.py
Normal file
384
teos/inspector.py
Normal file
@@ -0,0 +1,384 @@
|
||||
import re
|
||||
from binascii import unhexlify
|
||||
|
||||
import common.cryptographer
|
||||
from common.constants import LOCATOR_LEN_HEX
|
||||
from common.cryptographer import Cryptographer, PublicKey
|
||||
|
||||
from teos import errors, LOG_PREFIX
|
||||
from common.logger import Logger
|
||||
from common.appointment import Appointment
|
||||
from teos.block_processor import BlockProcessor
|
||||
|
||||
logger = Logger(actor="Inspector", log_name_prefix=LOG_PREFIX)
|
||||
common.cryptographer.logger = Logger(actor="Cryptographer", log_name_prefix=LOG_PREFIX)
|
||||
|
||||
# FIXME: The inspector logs the wrong messages sent form the users. A possible attack surface would be to send a really
|
||||
# long field that, even if not accepted by TEOS, would be stored in the logs. This is a possible DoS surface
|
||||
# since teos would store any kind of message (no matter the length). Solution: truncate the length of the fields
|
||||
# stored + blacklist if multiple wrong requests are received.
|
||||
|
||||
|
||||
BLOCKS_IN_A_MONTH = 4320 # 4320 = roughly a month in blocks
|
||||
ENCRYPTED_BLOB_MAX_SIZE_HEX = 2 * 2048
|
||||
|
||||
|
||||
class Inspector:
|
||||
"""
|
||||
The :class:`Inspector` class is in charge of verifying that the appointment data provided by the user is correct.
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
|
||||
def inspect(self, appointment_data, signature, public_key):
|
||||
"""
|
||||
Inspects whether the data provided by the user is correct.
|
||||
|
||||
Args:
|
||||
appointment_data (:obj:`dict`): a dictionary containing the appointment data.
|
||||
signature (:obj:`str`): the appointment signature provided by the user (hex encoded).
|
||||
public_key (:obj:`str`): the user's public key (hex encoded).
|
||||
|
||||
Returns:
|
||||
:obj:`Appointment <teos.appointment.Appointment>` or :obj:`tuple`: An appointment initialized with the
|
||||
provided data if it is correct.
|
||||
|
||||
Returns a tuple ``(return code, message)`` describing the error otherwise.
|
||||
|
||||
Errors are defined in :mod:`Errors <teos.errors>`.
|
||||
"""
|
||||
|
||||
block_height = BlockProcessor.get_block_count()
|
||||
|
||||
if block_height is not None:
|
||||
rcode, message = self.check_locator(appointment_data.get("locator"))
|
||||
|
||||
if rcode == 0:
|
||||
rcode, message = self.check_start_time(appointment_data.get("start_time"), block_height)
|
||||
if rcode == 0:
|
||||
rcode, message = self.check_end_time(
|
||||
appointment_data.get("end_time"), appointment_data.get("start_time"), block_height
|
||||
)
|
||||
if rcode == 0:
|
||||
rcode, message = self.check_to_self_delay(appointment_data.get("to_self_delay"))
|
||||
if rcode == 0:
|
||||
rcode, message = self.check_blob(appointment_data.get("encrypted_blob"))
|
||||
# if rcode == 0:
|
||||
# rcode, message = self.check_appointment_signature(appointment_data, signature, public_key)
|
||||
|
||||
if rcode == 0:
|
||||
r = Appointment.from_dict(appointment_data)
|
||||
else:
|
||||
r = (rcode, message)
|
||||
|
||||
else:
|
||||
# In case of an unknown exception, assign a special rcode and reason.
|
||||
r = (errors.UNKNOWN_JSON_RPC_EXCEPTION, "Unexpected error occurred")
|
||||
|
||||
return r
|
||||
|
||||
@staticmethod
|
||||
def check_locator(locator):
|
||||
"""
|
||||
Checks if the provided ``locator`` is correct.
|
||||
|
||||
Locators must be 16-byte hex encoded strings.
|
||||
|
||||
Args:
|
||||
locator (:obj:`str`): the locator to be checked.
|
||||
|
||||
Returns:
|
||||
:obj:`tuple`: A tuple (return code, message) as follows:
|
||||
|
||||
- ``(0, None)`` if the ``locator`` is correct.
|
||||
- ``!= (0, None)`` otherwise.
|
||||
|
||||
The possible return errors are: ``APPOINTMENT_EMPTY_FIELD``, ``APPOINTMENT_WRONG_FIELD_TYPE``,
|
||||
``APPOINTMENT_WRONG_FIELD_SIZE``, and ``APPOINTMENT_WRONG_FIELD_FORMAT``.
|
||||
"""
|
||||
|
||||
message = None
|
||||
rcode = 0
|
||||
|
||||
if locator is None:
|
||||
rcode = errors.APPOINTMENT_EMPTY_FIELD
|
||||
message = "empty locator received"
|
||||
|
||||
elif type(locator) != str:
|
||||
rcode = errors.APPOINTMENT_WRONG_FIELD_TYPE
|
||||
message = "wrong locator data type ({})".format(type(locator))
|
||||
|
||||
elif len(locator) != LOCATOR_LEN_HEX:
|
||||
rcode = errors.APPOINTMENT_WRONG_FIELD_SIZE
|
||||
message = "wrong locator size ({})".format(len(locator))
|
||||
# TODO: #12-check-txid-regexp
|
||||
|
||||
elif re.search(r"^[0-9A-Fa-f]+$", locator) is None:
|
||||
rcode = errors.APPOINTMENT_WRONG_FIELD_FORMAT
|
||||
message = "wrong locator format ({})".format(locator)
|
||||
|
||||
if message is not None:
|
||||
logger.error(message)
|
||||
|
||||
return rcode, message
|
||||
|
||||
@staticmethod
|
||||
def check_start_time(start_time, block_height):
|
||||
"""
|
||||
Checks if the provided ``start_time`` is correct.
|
||||
|
||||
Start times must be ahead the current best chain tip.
|
||||
|
||||
Args:
|
||||
start_time (:obj:`int`): the block height at which the tower is requested to start watching for breaches.
|
||||
block_height (:obj:`int`): the chain height.
|
||||
|
||||
Returns:
|
||||
:obj:`tuple`: A tuple (return code, message) as follows:
|
||||
|
||||
- ``(0, None)`` if the ``start_time`` is correct.
|
||||
- ``!= (0, None)`` otherwise.
|
||||
|
||||
The possible return errors are: ``APPOINTMENT_EMPTY_FIELD``, ``APPOINTMENT_WRONG_FIELD_TYPE``, and
|
||||
``APPOINTMENT_FIELD_TOO_SMALL``.
|
||||
"""
|
||||
|
||||
message = None
|
||||
rcode = 0
|
||||
|
||||
# TODO: What's too close to the current height is not properly defined. Right now any appointment that is in the
|
||||
# future will be accepted (even if it's only one block away).
|
||||
|
||||
t = type(start_time)
|
||||
|
||||
if start_time is None:
|
||||
rcode = errors.APPOINTMENT_EMPTY_FIELD
|
||||
message = "empty start_time received"
|
||||
|
||||
elif t != int:
|
||||
rcode = errors.APPOINTMENT_WRONG_FIELD_TYPE
|
||||
message = "wrong start_time data type ({})".format(t)
|
||||
|
||||
elif start_time <= block_height:
|
||||
rcode = errors.APPOINTMENT_FIELD_TOO_SMALL
|
||||
if start_time < block_height:
|
||||
message = "start_time is in the past"
|
||||
else:
|
||||
message = (
|
||||
"start_time is too close to current height. "
|
||||
"Accepted times are: [current_height+1, current_height+6]"
|
||||
)
|
||||
|
||||
elif start_time > block_height + 6:
|
||||
rcode = errors.APPOINTMENT_FIELD_TOO_BIG
|
||||
message = "start_time is too far in the future. Accepted start times are up to 6 blocks in the future"
|
||||
|
||||
if message is not None:
|
||||
logger.error(message)
|
||||
|
||||
return rcode, message
|
||||
|
||||
@staticmethod
|
||||
def check_end_time(end_time, start_time, block_height):
|
||||
"""
|
||||
Checks if the provided ``end_time`` is correct.
|
||||
|
||||
End times must be ahead both the ``start_time`` and the current best chain tip.
|
||||
|
||||
Args:
|
||||
end_time (:obj:`int`): the block height at which the tower is requested to stop watching for breaches.
|
||||
start_time (:obj:`int`): the block height at which the tower is requested to start watching for breaches.
|
||||
block_height (:obj:`int`): the chain height.
|
||||
|
||||
Returns:
|
||||
:obj:`tuple`: A tuple (return code, message) as follows:
|
||||
|
||||
- ``(0, None)`` if the ``end_time`` is correct.
|
||||
- ``!= (0, None)`` otherwise.
|
||||
|
||||
The possible return errors are: ``APPOINTMENT_EMPTY_FIELD``, ``APPOINTMENT_WRONG_FIELD_TYPE``, and
|
||||
``APPOINTMENT_FIELD_TOO_SMALL``.
|
||||
"""
|
||||
|
||||
message = None
|
||||
rcode = 0
|
||||
|
||||
# TODO: What's too close to the current height is not properly defined. Right now any appointment that ends in
|
||||
# the future will be accepted (even if it's only one block away).
|
||||
|
||||
t = type(end_time)
|
||||
|
||||
if end_time is None:
|
||||
rcode = errors.APPOINTMENT_EMPTY_FIELD
|
||||
message = "empty end_time received"
|
||||
|
||||
elif t != int:
|
||||
rcode = errors.APPOINTMENT_WRONG_FIELD_TYPE
|
||||
message = "wrong end_time data type ({})".format(t)
|
||||
|
||||
elif end_time > block_height + BLOCKS_IN_A_MONTH: # 4320 = roughly a month in blocks
|
||||
rcode = errors.APPOINTMENT_FIELD_TOO_BIG
|
||||
message = "end_time should be within the next month (<= current_height + 4320)"
|
||||
|
||||
elif start_time >= end_time:
|
||||
rcode = errors.APPOINTMENT_FIELD_TOO_SMALL
|
||||
if start_time > end_time:
|
||||
message = "end_time is smaller than start_time"
|
||||
else:
|
||||
message = "end_time is equal to start_time"
|
||||
|
||||
elif block_height >= end_time:
|
||||
rcode = errors.APPOINTMENT_FIELD_TOO_SMALL
|
||||
if block_height > end_time:
|
||||
message = "end_time is in the past"
|
||||
else:
|
||||
message = "end_time is too close to current height"
|
||||
|
||||
if message is not None:
|
||||
logger.error(message)
|
||||
|
||||
return rcode, message
|
||||
|
||||
def check_to_self_delay(self, to_self_delay):
|
||||
"""
|
||||
Checks if the provided ``to_self_delay`` is correct.
|
||||
|
||||
To self delays must be greater or equal to ``MIN_TO_SELF_DELAY``.
|
||||
|
||||
Args:
|
||||
to_self_delay (:obj:`int`): The ``to_self_delay`` encoded in the ``csv`` of the ``htlc`` that this
|
||||
appointment is covering.
|
||||
|
||||
Returns:
|
||||
:obj:`tuple`: A tuple (return code, message) as follows:
|
||||
|
||||
- ``(0, None)`` if the ``to_self_delay`` is correct.
|
||||
- ``!= (0, None)`` otherwise.
|
||||
|
||||
The possible return errors are: ``APPOINTMENT_EMPTY_FIELD``, ``APPOINTMENT_WRONG_FIELD_TYPE``, and
|
||||
``APPOINTMENT_FIELD_TOO_SMALL``.
|
||||
"""
|
||||
|
||||
message = None
|
||||
rcode = 0
|
||||
|
||||
t = type(to_self_delay)
|
||||
|
||||
if to_self_delay is None:
|
||||
rcode = errors.APPOINTMENT_EMPTY_FIELD
|
||||
message = "empty to_self_delay received"
|
||||
|
||||
elif t != int:
|
||||
rcode = errors.APPOINTMENT_WRONG_FIELD_TYPE
|
||||
message = "wrong to_self_delay data type ({})".format(t)
|
||||
|
||||
elif to_self_delay > pow(2, 32):
|
||||
rcode = errors.APPOINTMENT_FIELD_TOO_BIG
|
||||
message = "to_self_delay must fit the transaction nLockTime field ({} > {})".format(
|
||||
to_self_delay, pow(2, 32)
|
||||
)
|
||||
|
||||
elif to_self_delay < self.config.get("MIN_TO_SELF_DELAY"):
|
||||
rcode = errors.APPOINTMENT_FIELD_TOO_SMALL
|
||||
message = "to_self_delay too small. The to_self_delay should be at least {} (current: {})".format(
|
||||
self.config.get("MIN_TO_SELF_DELAY"), to_self_delay
|
||||
)
|
||||
|
||||
if message is not None:
|
||||
logger.error(message)
|
||||
|
||||
return rcode, message
|
||||
|
||||
# ToDo: #6-define-checks-encrypted-blob
|
||||
@staticmethod
|
||||
def check_blob(encrypted_blob):
|
||||
"""
|
||||
Checks if the provided ``encrypted_blob`` may be correct.
|
||||
|
||||
Args:
|
||||
encrypted_blob (:obj:`str`): the encrypted blob to be checked (hex encoded).
|
||||
|
||||
Returns:
|
||||
:obj:`tuple`: A tuple (return code, message) as follows:
|
||||
|
||||
- ``(0, None)`` if the ``encrypted_blob`` is correct.
|
||||
- ``!= (0, None)`` otherwise.
|
||||
|
||||
The possible return errors are: ``APPOINTMENT_EMPTY_FIELD``, ``APPOINTMENT_WRONG_FIELD_TYPE``, and
|
||||
``APPOINTMENT_WRONG_FIELD_FORMAT``.
|
||||
"""
|
||||
|
||||
message = None
|
||||
rcode = 0
|
||||
|
||||
t = type(encrypted_blob)
|
||||
|
||||
if encrypted_blob is None:
|
||||
rcode = errors.APPOINTMENT_EMPTY_FIELD
|
||||
message = "empty encrypted_blob received"
|
||||
|
||||
elif t != str:
|
||||
rcode = errors.APPOINTMENT_WRONG_FIELD_TYPE
|
||||
message = "wrong encrypted_blob data type ({})".format(t)
|
||||
|
||||
elif len(encrypted_blob) > ENCRYPTED_BLOB_MAX_SIZE_HEX:
|
||||
rcode = errors.APPOINTMENT_FIELD_TOO_BIG
|
||||
message = "encrypted_blob has to be 2Kib at most (current {})".format(len(encrypted_blob) // 2)
|
||||
|
||||
elif re.search(r"^[0-9A-Fa-f]+$", encrypted_blob) is None:
|
||||
rcode = errors.APPOINTMENT_WRONG_FIELD_FORMAT
|
||||
message = "wrong encrypted_blob format ({})".format(encrypted_blob)
|
||||
|
||||
if message is not None:
|
||||
logger.error(message)
|
||||
|
||||
return rcode, message
|
||||
|
||||
@staticmethod
|
||||
# Verifies that the appointment signature is a valid signature with public key
|
||||
def check_appointment_signature(appointment_data, signature, pk):
|
||||
"""
|
||||
Checks if the provided user signature is correct.
|
||||
|
||||
Args:
|
||||
appointment_data (:obj:`dict`): the appointment that was signed by the user.
|
||||
signature (:obj:`str`): the user's signature (hex encoded).
|
||||
pk (:obj:`str`): the user's public key (hex encoded).
|
||||
|
||||
Returns:
|
||||
:obj:`tuple`: A tuple (return code, message) as follows:
|
||||
|
||||
- ``(0, None)`` if the ``signature`` is correct.
|
||||
- ``!= (0, None)`` otherwise.
|
||||
|
||||
The possible return errors are: ``APPOINTMENT_EMPTY_FIELD``, ``APPOINTMENT_WRONG_FIELD_TYPE``, and
|
||||
``APPOINTMENT_WRONG_FIELD_FORMAT``.
|
||||
"""
|
||||
|
||||
message = None
|
||||
rcode = 0
|
||||
|
||||
if signature is None:
|
||||
rcode = errors.APPOINTMENT_EMPTY_FIELD
|
||||
message = "empty signature received"
|
||||
|
||||
elif pk is None:
|
||||
rcode = errors.APPOINTMENT_EMPTY_FIELD
|
||||
message = "empty public key received"
|
||||
|
||||
elif re.match(r"^[0-9A-Fa-f]{66}$", pk) is None:
|
||||
rcode = errors.APPOINTMENT_WRONG_FIELD
|
||||
message = "public key must be a hex encoded 33-byte long value"
|
||||
|
||||
else:
|
||||
appointment = Appointment.from_dict(appointment_data)
|
||||
rpk = Cryptographer.recover_pk(appointment.serialize(), signature)
|
||||
pk = PublicKey(unhexlify(pk))
|
||||
valid_sig = Cryptographer.verify_rpk(pk, rpk)
|
||||
|
||||
if not valid_sig:
|
||||
rcode = errors.APPOINTMENT_INVALID_SIGNATURE
|
||||
message = "invalid signature"
|
||||
|
||||
return rcode, message
|
||||
3
teos/requirements-dev.txt
Normal file
3
teos/requirements-dev.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
pytest
|
||||
black
|
||||
bitcoind_mock===0.0.4
|
||||
7
teos/requirements.txt
Normal file
7
teos/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
zmq
|
||||
flask
|
||||
cryptography
|
||||
coincurve
|
||||
pyzbase32
|
||||
requests
|
||||
plyvel
|
||||
488
teos/responder.py
Normal file
488
teos/responder.py
Normal file
@@ -0,0 +1,488 @@
|
||||
import json
|
||||
from queue import Queue
|
||||
from threading import Thread
|
||||
|
||||
from teos import LOG_PREFIX
|
||||
from common.logger import Logger
|
||||
from teos.cleaner import Cleaner
|
||||
from teos.carrier import Carrier
|
||||
from teos.block_processor import BlockProcessor
|
||||
|
||||
CONFIRMATIONS_BEFORE_RETRY = 6
|
||||
MIN_CONFIRMATIONS = 6
|
||||
|
||||
logger = Logger(actor="Responder", log_name_prefix=LOG_PREFIX)
|
||||
|
||||
|
||||
class TransactionTracker:
|
||||
"""
|
||||
A :class:`TransactionTracker` is used to monitor a ``penalty_tx``. Once the dispute is seen by the
|
||||
:obj:`Watcher <teos.watcher.Watcher>` the penalty transaction is decrypted and the relevant appointment data is
|
||||
passed along to the :obj:`Responder`.
|
||||
|
||||
Once the :obj:`Responder` has succeeded on broadcasting the penalty transaction it will create a
|
||||
:obj:`TransactionTracker` and monitor the blockchain until the end of the appointment.
|
||||
|
||||
Args:
|
||||
locator (:obj:`str`): A 16-byte hex-encoded value used by the tower to detect channel breaches. It serves as a
|
||||
trigger for the tower to decrypt and broadcast the penalty transaction.
|
||||
dispute_txid (:obj:`str`): the id of the transaction that created the channel breach and triggered the penalty.
|
||||
penalty_txid (:obj:`str`): the id of the transaction that was encrypted under ``dispute_txid``.
|
||||
penalty_rawtx (:obj:`str`): the raw transaction that was broadcast as a consequence of the channel breach.
|
||||
appointment_end (:obj:`int`): the block at which the tower will stop monitoring the blockchain for this
|
||||
appointment.
|
||||
"""
|
||||
|
||||
def __init__(self, locator, dispute_txid, penalty_txid, penalty_rawtx, appointment_end):
|
||||
self.locator = locator
|
||||
self.dispute_txid = dispute_txid
|
||||
self.penalty_txid = penalty_txid
|
||||
self.penalty_rawtx = penalty_rawtx
|
||||
self.appointment_end = appointment_end
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, tx_tracker_data):
|
||||
"""
|
||||
Constructs a :obj:`TransactionTracker` instance from a dictionary. Requires that all the fields are populated
|
||||
(``not None``).
|
||||
|
||||
Useful to load data from the database.
|
||||
|
||||
Args:
|
||||
tx_tracker_data (:obj:`dict`): a dictionary with an entry per each field required to create the
|
||||
:obj:`TransactionTracker`.
|
||||
|
||||
Returns:
|
||||
:obj:`TransactionTracker`: A ``TransactionTracker`` instantiated with the provided data.
|
||||
|
||||
Raises:
|
||||
ValueError: if any of the required fields is missing.
|
||||
"""
|
||||
|
||||
locator = tx_tracker_data.get("locator")
|
||||
dispute_txid = tx_tracker_data.get("dispute_txid")
|
||||
penalty_txid = tx_tracker_data.get("penalty_txid")
|
||||
penalty_rawtx = tx_tracker_data.get("penalty_rawtx")
|
||||
appointment_end = tx_tracker_data.get("appointment_end")
|
||||
|
||||
if any(v is None for v in [locator, dispute_txid, penalty_txid, penalty_rawtx, appointment_end]):
|
||||
raise ValueError("Wrong transaction tracker data, some fields are missing")
|
||||
|
||||
else:
|
||||
tx_tracker = cls(locator, dispute_txid, penalty_txid, penalty_rawtx, appointment_end)
|
||||
|
||||
return tx_tracker
|
||||
|
||||
def to_dict(self):
|
||||
"""
|
||||
Exports a :obj:`TransactionTracker` as a dictionary.
|
||||
|
||||
Returns:
|
||||
:obj:`dict`: A dictionary containing the :obj:`TransactionTracker` data.
|
||||
"""
|
||||
|
||||
tx_tracker = {
|
||||
"locator": self.locator,
|
||||
"dispute_txid": self.dispute_txid,
|
||||
"penalty_txid": self.penalty_txid,
|
||||
"penalty_rawtx": self.penalty_rawtx,
|
||||
"appointment_end": self.appointment_end,
|
||||
}
|
||||
|
||||
return tx_tracker
|
||||
|
||||
def to_json(self):
|
||||
"""
|
||||
Exports a :obj:`TransactionTracker` as a json-encoded dictionary.
|
||||
|
||||
Returns:
|
||||
:obj:`str`: A json-encoded dictionary containing the :obj:`TransactionTracker` data.
|
||||
"""
|
||||
|
||||
return json.dumps(self.to_dict())
|
||||
|
||||
|
||||
class Responder:
|
||||
"""
|
||||
The :class:`Responder` is the class in charge of ensuring that channel breaches are dealt with. It does so handling
|
||||
the decrypted ``penalty_txs`` handed by the :obj:`Watcher <teos.watcher.Watcher>` and ensuring the they make it to
|
||||
the blockchain.
|
||||
|
||||
Args:
|
||||
db_manager (:obj:`DBManager <teos.db_manager.DBManager>`): a ``DBManager`` instance to interact with the
|
||||
database.
|
||||
|
||||
Attributes:
|
||||
trackers (:obj:`dict`): A dictionary containing the minimum information about the :obj:`TransactionTracker`
|
||||
required by the :obj:`Responder` (``penalty_txid``, ``locator`` and ``end_time``).
|
||||
Each entry is identified by a ``uuid``.
|
||||
tx_tracker_map (:obj:`dict`): A ``penalty_txid:uuid`` map used to allow the :obj:`Responder` to deal with
|
||||
several trackers triggered by the same ``penalty_txid``.
|
||||
unconfirmed_txs (:obj:`list`): A list that keeps track of all unconfirmed ``penalty_txs``.
|
||||
missed_confirmations (:obj:`dict`): A dictionary that keeps count of how many confirmations each ``penalty_tx``
|
||||
has missed. Used to trigger rebroadcast if needed.
|
||||
block_queue (:obj:`Queue`): A queue used by the :obj:`Responder` to receive block hashes from ``bitcoind``. It
|
||||
is populated by the :obj:`ChainMonitor <teos.chain_monitor.ChainMonitor>`.
|
||||
db_manager (:obj:`DBManager <teos.db_manager.DBManager>`): A ``DBManager`` instance to interact with the
|
||||
database.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, db_manager):
|
||||
self.trackers = dict()
|
||||
self.tx_tracker_map = dict()
|
||||
self.unconfirmed_txs = []
|
||||
self.missed_confirmations = dict()
|
||||
self.block_queue = Queue()
|
||||
self.db_manager = db_manager
|
||||
self.carrier = Carrier()
|
||||
self.last_known_block = db_manager.load_last_block_hash_responder()
|
||||
|
||||
def awake(self):
|
||||
responder_thread = Thread(target=self.do_watch, daemon=True)
|
||||
responder_thread.start()
|
||||
|
||||
return responder_thread
|
||||
|
||||
@staticmethod
|
||||
def on_sync(block_hash):
|
||||
"""
|
||||
Whether the :obj:`Responder` is on sync with ``bitcoind`` or not. Used when recovering from a crash.
|
||||
|
||||
The Watchtower can be instantiated with fresh or with backed up data. In the later, some triggers may have been
|
||||
missed. In order to go back on sync both the :obj:`Watcher <teos.watcher.Watcher>` and the :obj:`Responder`
|
||||
need to perform the state transitions until they catch up.
|
||||
|
||||
If a transaction is broadcast by the :obj:`Responder` and it is rejected (due to a double-spending for example)
|
||||
and the :obj:`Responder` is off-sync then the :obj:`TransactionTracker` is abandoned.
|
||||
|
||||
This method helps making that decision.
|
||||
|
||||
Args:
|
||||
block_hash (:obj:`str`): the block hash passed to the :obj:`Responder` in the ``handle_breach`` request.
|
||||
|
||||
Returns:
|
||||
:obj:`bool`: Whether or not the :obj:`Responder` and ``bitcoind`` are on sync.
|
||||
"""
|
||||
|
||||
block_processor = BlockProcessor()
|
||||
distance_from_tip = block_processor.get_distance_to_tip(block_hash)
|
||||
|
||||
if distance_from_tip is not None and distance_from_tip > 1:
|
||||
synchronized = False
|
||||
|
||||
else:
|
||||
synchronized = True
|
||||
|
||||
return synchronized
|
||||
|
||||
def handle_breach(self, uuid, locator, dispute_txid, penalty_txid, penalty_rawtx, appointment_end, block_hash):
|
||||
"""
|
||||
Requests the :obj:`Responder` to handle a channel breach. This is the entry point of the :obj:`Responder`.
|
||||
|
||||
Args:
|
||||
uuid (:obj:`str`): a unique identifier for the appointment.
|
||||
locator (:obj:`str`): the appointment locator provided by the user (16-byte hex-encoded).
|
||||
dispute_txid (:obj:`str`): the id of the transaction that created the channel breach.
|
||||
penalty_txid (:obj:`str`): the id of the decrypted transaction included in the appointment.
|
||||
penalty_rawtx (:obj:`str`): the raw transaction to be broadcast in response of the breach.
|
||||
appointment_end (:obj:`int`): the block height at which the :obj:`Responder` will stop monitoring for this
|
||||
penalty transaction.
|
||||
block_hash (:obj:`str`): the block hash at which the breach was seen (used to see if we are on sync).
|
||||
|
||||
Returns:
|
||||
:obj:`Receipt <teos.carrier.Receipt>`: A ``Receipt`` indicating whether or not the ``penalty_tx`` made it
|
||||
into the blockchain.
|
||||
"""
|
||||
|
||||
receipt = self.carrier.send_transaction(penalty_rawtx, penalty_txid)
|
||||
|
||||
if receipt.delivered:
|
||||
self.add_tracker(
|
||||
uuid, locator, dispute_txid, penalty_txid, penalty_rawtx, appointment_end, receipt.confirmations
|
||||
)
|
||||
|
||||
else:
|
||||
# TODO: Add the missing reasons (e.g. RPC_VERIFY_REJECTED)
|
||||
# TODO: Use self.on_sync(block_hash) to check whether or not we failed because we are out of sync
|
||||
logger.warning(
|
||||
"Tracker cannot be created", reason=receipt.reason, uuid=uuid, on_sync=self.on_sync(block_hash)
|
||||
)
|
||||
|
||||
return receipt
|
||||
|
||||
def add_tracker(self, uuid, locator, dispute_txid, penalty_txid, penalty_rawtx, appointment_end, confirmations=0):
|
||||
"""
|
||||
Creates a :obj:`TransactionTracker` after successfully broadcasting a ``penalty_tx``.
|
||||
|
||||
A reduction of :obj:`TransactionTracker` is stored in ``trackers`` and ``tx_tracker_map`` and the
|
||||
``penalty_txid`` added to ``unconfirmed_txs`` if ``confirmations=0``. Finally, all the data is stored in the
|
||||
database.
|
||||
|
||||
Args:
|
||||
uuid (:obj:`str`): a unique identifier for the appointment.
|
||||
locator (:obj:`str`): the appointment locator provided by the user (16-byte hex-encoded).
|
||||
dispute_txid (:obj:`str`): the id of the transaction that created the channel breach.
|
||||
penalty_txid (:obj:`str`): the id of the decrypted transaction included in the appointment.
|
||||
penalty_rawtx (:obj:`str`): the raw transaction to be broadcast.
|
||||
appointment_end (:obj:`int`): the block height at which the :obj:`Responder` will stop monitoring for the
|
||||
tracker.
|
||||
confirmations (:obj:`int`): the confirmation count of the ``penalty_tx``. In normal conditions it will be
|
||||
zero, but if the transaction is already on the blockchain this won't be the case.
|
||||
"""
|
||||
|
||||
tracker = TransactionTracker(locator, dispute_txid, penalty_txid, penalty_rawtx, appointment_end)
|
||||
|
||||
# We only store the penalty_txid, locator and appointment_end in memory. The rest is dumped into the db.
|
||||
self.trackers[uuid] = {
|
||||
"penalty_txid": tracker.penalty_txid,
|
||||
"locator": locator,
|
||||
"appointment_end": appointment_end,
|
||||
}
|
||||
|
||||
if penalty_txid in self.tx_tracker_map:
|
||||
self.tx_tracker_map[penalty_txid].append(uuid)
|
||||
|
||||
else:
|
||||
self.tx_tracker_map[penalty_txid] = [uuid]
|
||||
|
||||
# In the case we receive two trackers with the same penalty txid we only add it to the unconfirmed txs list once
|
||||
if penalty_txid not in self.unconfirmed_txs and confirmations == 0:
|
||||
self.unconfirmed_txs.append(penalty_txid)
|
||||
|
||||
self.db_manager.store_responder_tracker(uuid, tracker.to_json())
|
||||
|
||||
logger.info(
|
||||
"New tracker added", dispute_txid=dispute_txid, penalty_txid=penalty_txid, appointment_end=appointment_end
|
||||
)
|
||||
|
||||
def do_watch(self):
|
||||
"""
|
||||
Monitors the blockchain whilst there are pending trackers.
|
||||
|
||||
This is the main method of the :obj:`Responder` and triggers tracker cleaning, rebroadcasting, reorg managing,
|
||||
etc.
|
||||
"""
|
||||
|
||||
# Distinguish fresh bootstraps from bootstraps from db
|
||||
if self.last_known_block is None:
|
||||
self.last_known_block = BlockProcessor.get_best_block_hash()
|
||||
|
||||
while True:
|
||||
block_hash = self.block_queue.get()
|
||||
block = BlockProcessor.get_block(block_hash)
|
||||
logger.info("New block received", block_hash=block_hash, prev_block_hash=block.get("previousblockhash"))
|
||||
|
||||
if len(self.trackers) > 0 and block is not None:
|
||||
txids = block.get("tx")
|
||||
|
||||
if self.last_known_block == block.get("previousblockhash"):
|
||||
self.check_confirmations(txids)
|
||||
|
||||
height = block.get("height")
|
||||
completed_trackers = self.get_completed_trackers(height)
|
||||
Cleaner.delete_completed_trackers(
|
||||
completed_trackers, height, self.trackers, self.tx_tracker_map, self.db_manager
|
||||
)
|
||||
|
||||
txs_to_rebroadcast = self.get_txs_to_rebroadcast()
|
||||
self.rebroadcast(txs_to_rebroadcast)
|
||||
|
||||
# NOTCOVERED
|
||||
else:
|
||||
logger.warning(
|
||||
"Reorg found",
|
||||
local_prev_block_hash=self.last_known_block,
|
||||
remote_prev_block_hash=block.get("previousblockhash"),
|
||||
)
|
||||
|
||||
# ToDo: #24-properly-handle-reorgs
|
||||
self.handle_reorgs(block_hash)
|
||||
|
||||
# Clear the receipts issued in this block
|
||||
self.carrier.issued_receipts = {}
|
||||
|
||||
if len(self.trackers) is 0:
|
||||
logger.info("No more pending trackers")
|
||||
|
||||
# Register the last processed block for the responder
|
||||
self.db_manager.store_last_block_hash_responder(block_hash)
|
||||
self.last_known_block = block.get("hash")
|
||||
self.block_queue.task_done()
|
||||
|
||||
def check_confirmations(self, txs):
|
||||
"""
|
||||
Checks if any of the monitored ``penalty_txs`` has received it's first confirmation or keeps missing them.
|
||||
|
||||
This method manages ``unconfirmed_txs`` and ``missed_confirmations``.
|
||||
|
||||
Args:
|
||||
txs (:obj:`list`): A list of confirmed tx ids (the list of transactions included in the last received
|
||||
block).
|
||||
"""
|
||||
|
||||
# If a new confirmed tx matches a tx we are watching, then we remove it from the unconfirmed txs map
|
||||
for tx in txs:
|
||||
if tx in self.tx_tracker_map and tx in self.unconfirmed_txs:
|
||||
self.unconfirmed_txs.remove(tx)
|
||||
|
||||
logger.info("Confirmation received for transaction", tx=tx)
|
||||
|
||||
# We also add a missing confirmation to all those txs waiting to be confirmed that have not been confirmed in
|
||||
# the current block
|
||||
for tx in self.unconfirmed_txs:
|
||||
if tx in self.missed_confirmations:
|
||||
self.missed_confirmations[tx] += 1
|
||||
|
||||
else:
|
||||
self.missed_confirmations[tx] = 1
|
||||
|
||||
logger.info("Transaction missed a confirmation", tx=tx, missed_confirmations=self.missed_confirmations[tx])
|
||||
|
||||
def get_txs_to_rebroadcast(self):
|
||||
"""
|
||||
Gets the transactions to be rebroadcast based on their ``missed_confirmation`` count.
|
||||
|
||||
Returns:
|
||||
:obj:`list`: A list with all the ids of the transaction that have to be rebroadcast.
|
||||
"""
|
||||
|
||||
txs_to_rebroadcast = []
|
||||
|
||||
for tx, missed_conf in self.missed_confirmations.items():
|
||||
if missed_conf >= CONFIRMATIONS_BEFORE_RETRY:
|
||||
# If a transactions has missed too many confirmations we add it to the rebroadcast list
|
||||
txs_to_rebroadcast.append(tx)
|
||||
|
||||
return txs_to_rebroadcast
|
||||
|
||||
def get_completed_trackers(self, height):
|
||||
"""
|
||||
Gets the trackers that has already been fulfilled based on a given height (``end_time`` was reached with a
|
||||
minimum confirmation count).
|
||||
|
||||
Args:
|
||||
height (:obj:`int`): the height of the last received block.
|
||||
|
||||
Returns:
|
||||
:obj:`dict`: a dict (``uuid:confirmations``) of the completed trackers.
|
||||
"""
|
||||
|
||||
completed_trackers = {}
|
||||
checked_txs = {}
|
||||
|
||||
for uuid, tracker_data in self.trackers.items():
|
||||
appointment_end = tracker_data.get("appointment_end")
|
||||
penalty_txid = tracker_data.get("penalty_txid")
|
||||
if appointment_end <= height and penalty_txid not in self.unconfirmed_txs:
|
||||
|
||||
if penalty_txid not in checked_txs:
|
||||
tx = Carrier.get_transaction(penalty_txid)
|
||||
else:
|
||||
tx = checked_txs.get(penalty_txid)
|
||||
|
||||
if tx is not None:
|
||||
confirmations = tx.get("confirmations")
|
||||
checked_txs[penalty_txid] = tx
|
||||
|
||||
if confirmations is not None and confirmations >= MIN_CONFIRMATIONS:
|
||||
# The end of the appointment has been reached
|
||||
completed_trackers[uuid] = confirmations
|
||||
|
||||
return completed_trackers
|
||||
|
||||
def rebroadcast(self, txs_to_rebroadcast):
|
||||
"""
|
||||
Rebroadcasts a ``penalty_tx`` that has missed too many confirmations. In the current approach this would loop
|
||||
forever si the transaction keeps not getting it.
|
||||
|
||||
Potentially the fees could be bumped here if the transaction has some tower dedicated outputs (or allows it
|
||||
trough ``ANYONECANPAY`` or something similar).
|
||||
|
||||
Args:
|
||||
txs_to_rebroadcast (:obj:`list`): a list of transactions to be rebroadcast.
|
||||
|
||||
Returns:
|
||||
:obj:`list`: A list of :obj:`Receipts <teos.carrier.Receipt>` with information about whether or not every
|
||||
transaction made it trough the network.
|
||||
"""
|
||||
|
||||
# DISCUSS: #22-discuss-confirmations-before-retry
|
||||
# ToDo: #23-define-behaviour-approaching-end
|
||||
|
||||
receipts = []
|
||||
|
||||
for txid in txs_to_rebroadcast:
|
||||
self.missed_confirmations[txid] = 0
|
||||
|
||||
# FIXME: This would potentially grab multiple instances of the same transaction and try to send them.
|
||||
# should we do it only once?
|
||||
for uuid in self.tx_tracker_map[txid]:
|
||||
tracker = TransactionTracker.from_dict(self.db_manager.load_responder_tracker(uuid))
|
||||
logger.warning(
|
||||
"Transaction has missed many confirmations. Rebroadcasting", penalty_txid=tracker.penalty_txid
|
||||
)
|
||||
|
||||
receipt = self.carrier.send_transaction(tracker.penalty_rawtx, tracker.penalty_txid)
|
||||
receipts.append((txid, receipt))
|
||||
|
||||
if not receipt.delivered:
|
||||
# FIXME: Can this actually happen?
|
||||
logger.warning("Transaction failed", penalty_txid=tracker.penalty_txid)
|
||||
|
||||
return receipts
|
||||
|
||||
# NOTCOVERED
|
||||
def handle_reorgs(self, block_hash):
|
||||
"""
|
||||
Basic reorg handle. It deals with situations where a reorg has been found but the ``dispute_tx`` is still
|
||||
on the chain. If the ``dispute_tx`` is reverted, it need to call the :obj:`ReorgManager` (Soon TM).
|
||||
|
||||
Args:
|
||||
block_hash (:obj:`str`): the hash of the last block received (which triggered the reorg).
|
||||
|
||||
"""
|
||||
|
||||
for uuid in self.trackers.keys():
|
||||
tracker = TransactionTracker.from_dict(self.db_manager.load_responder_tracker(uuid))
|
||||
|
||||
# First we check if the dispute transaction is known (exists either in mempool or blockchain)
|
||||
dispute_tx = self.carrier.get_transaction(tracker.dispute_txid)
|
||||
|
||||
if dispute_tx is not None:
|
||||
# If the dispute is there, we check the penalty
|
||||
penalty_tx = self.carrier.get_transaction(tracker.penalty_txid)
|
||||
|
||||
if penalty_tx is not None:
|
||||
# If the penalty exists we need to check is it's on the blockchain or not so we can update the
|
||||
# unconfirmed transactions list accordingly.
|
||||
if penalty_tx.get("confirmations") is None:
|
||||
self.unconfirmed_txs.append(tracker.penalty_txid)
|
||||
|
||||
logger.info(
|
||||
"Penalty transaction back in mempool. Updating unconfirmed transactions",
|
||||
penalty_txid=tracker.penalty_txid,
|
||||
)
|
||||
|
||||
else:
|
||||
# If the penalty transaction is missing, we need to reset the tracker.
|
||||
self.handle_breach(
|
||||
tracker.locator,
|
||||
uuid,
|
||||
tracker.dispute_txid,
|
||||
tracker.penalty_txid,
|
||||
tracker.penalty_rawtx,
|
||||
tracker.appointment_end,
|
||||
block_hash,
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
"Penalty transaction banished. Resetting the tracker", penalty_tx=tracker.penalty_txid
|
||||
)
|
||||
|
||||
else:
|
||||
# ToDo: #24-properly-handle-reorgs
|
||||
# FIXME: if the dispute is not on chain (either in mempool or not there at all), we need to call the
|
||||
# reorg manager
|
||||
logger.warning("Dispute and penalty transaction missing. Calling the reorg manager")
|
||||
logger.error("Reorg manager not yet implemented")
|
||||
44
teos/rpc_errors.py
Normal file
44
teos/rpc_errors.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# Ported from https://github.com/bitcoin/bitcoin/blob/0.18/src/rpc/protocol.h
|
||||
|
||||
# General application defined errors
|
||||
RPC_MISC_ERROR = -1 # std::exception thrown in command handling
|
||||
RPC_TYPE_ERROR = -3 # Unexpected type was passed as parameter
|
||||
RPC_INVALID_ADDRESS_OR_KEY = -5 # Invalid address or key
|
||||
RPC_OUT_OF_MEMORY = -7 # Ran out of memory during operation
|
||||
RPC_INVALID_PARAMETER = -8 # Invalid missing or duplicate parameter
|
||||
RPC_DATABASE_ERROR = -20 # Database error
|
||||
RPC_DESERIALIZATION_ERROR = -22 # Error parsing or validating structure in raw format
|
||||
RPC_VERIFY_ERROR = -25 # General error during transaction or block submission
|
||||
RPC_VERIFY_REJECTED = -26 # Transaction or block was rejected by network rules
|
||||
RPC_VERIFY_ALREADY_IN_CHAIN = -27 # Transaction already in chain
|
||||
RPC_IN_WARMUP = -28 # Client still warming up
|
||||
RPC_METHOD_DEPRECATED = -32 # RPC method is deprecated
|
||||
|
||||
# Aliases for backward compatibility
|
||||
RPC_TRANSACTION_ERROR = RPC_VERIFY_ERROR
|
||||
RPC_TRANSACTION_REJECTED = RPC_VERIFY_REJECTED
|
||||
RPC_TRANSACTION_ALREADY_IN_CHAIN = RPC_VERIFY_ALREADY_IN_CHAIN
|
||||
|
||||
# P2P client errors
|
||||
RPC_CLIENT_NOT_CONNECTED = -9 # Bitcoin is not connected
|
||||
RPC_CLIENT_IN_INITIAL_DOWNLOAD = -10 # Still downloading initial blocks
|
||||
RPC_CLIENT_NODE_ALREADY_ADDED = -23 # Node is already added
|
||||
RPC_CLIENT_NODE_NOT_ADDED = -24 # Node has not been added before
|
||||
RPC_CLIENT_NODE_NOT_CONNECTED = -29 # Node to disconnect not found in connected nodes
|
||||
RPC_CLIENT_INVALID_IP_OR_SUBNET = -30 # Invalid IP/Subnet
|
||||
RPC_CLIENT_P2P_DISABLED = -31 # No valid connection manager instance found
|
||||
|
||||
# Wallet errors
|
||||
RPC_WALLET_ERROR = -4 # Unspecified problem with wallet (key not found etc.)
|
||||
RPC_WALLET_INSUFFICIENT_FUNDS = -6 # Not enough funds in wallet or account
|
||||
RPC_WALLET_INVALID_LABEL_NAME = -11 # Invalid label name
|
||||
RPC_WALLET_KEYPOOL_RAN_OUT = -12 # Keypool ran out call keypoolrefill first
|
||||
RPC_WALLET_UNLOCK_NEEDED = -13 # Enter the wallet passphrase with walletpassphrase first
|
||||
RPC_WALLET_PASSPHRASE_INCORRECT = -14 # The wallet passphrase entered was incorrect
|
||||
RPC_WALLET_WRONG_ENC_STATE = (
|
||||
-15
|
||||
) # Command given in wrong wallet encryption state (encrypting an encrypted wallet etc.)
|
||||
RPC_WALLET_ENCRYPTION_FAILED = -16 # Failed to encrypt the wallet
|
||||
RPC_WALLET_ALREADY_UNLOCKED = -17 # Wallet is already unlocked
|
||||
RPC_WALLET_NOT_FOUND = -18 # Invalid wallet specified
|
||||
RPC_WALLET_NOT_SPECIFIED = -19 # No wallet specified (error when there are multiple wallets loaded)
|
||||
26
teos/sample_conf.py
Normal file
26
teos/sample_conf.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# bitcoind
|
||||
BTC_RPC_USER = "user"
|
||||
BTC_RPC_PASSWD = "passwd"
|
||||
BTC_RPC_HOST = "localhost"
|
||||
BTC_RPC_PORT = 18443
|
||||
BTC_NETWORK = "regtest"
|
||||
|
||||
# ZMQ
|
||||
FEED_PROTOCOL = "tcp"
|
||||
FEED_ADDR = "127.0.0.1"
|
||||
FEED_PORT = 28332
|
||||
|
||||
# TEOS
|
||||
DATA_FOLDER = "~/.teos/"
|
||||
MAX_APPOINTMENTS = 100
|
||||
EXPIRY_DELTA = 6
|
||||
MIN_TO_SELF_DELAY = 20
|
||||
SERVER_LOG_FILE = "teos.log"
|
||||
TEOS_SECRET_KEY = "teos_sk.der"
|
||||
|
||||
# CHAIN MONITOR
|
||||
POLLING_DELTA = 60
|
||||
BLOCK_WINDOW_SIZE = 10
|
||||
|
||||
# LEVELDB
|
||||
DB_PATH = "appointments"
|
||||
138
teos/teosd.py
Normal file
138
teos/teosd.py
Normal file
@@ -0,0 +1,138 @@
|
||||
from getopt import getopt
|
||||
from sys import argv, exit
|
||||
from signal import signal, SIGINT, SIGQUIT, SIGTERM
|
||||
|
||||
import common.cryptographer
|
||||
from common.logger import Logger
|
||||
from common.cryptographer import Cryptographer
|
||||
|
||||
from teos import config, LOG_PREFIX
|
||||
from teos.api import API
|
||||
from teos.watcher import Watcher
|
||||
from teos.builder import Builder
|
||||
from teos.responder import Responder
|
||||
from teos.db_manager import DBManager
|
||||
from teos.chain_monitor import ChainMonitor
|
||||
from teos.block_processor import BlockProcessor
|
||||
from teos.tools import can_connect_to_bitcoind, in_correct_network
|
||||
|
||||
logger = Logger(actor="Daemon", log_name_prefix=LOG_PREFIX)
|
||||
common.cryptographer.logger = Logger(actor="Cryptographer", log_name_prefix=LOG_PREFIX)
|
||||
|
||||
|
||||
def handle_signals(signal_received, frame):
|
||||
logger.info("Closing connection with appointments db")
|
||||
db_manager.db.close()
|
||||
chain_monitor.terminate = True
|
||||
|
||||
logger.info("Shutting down TEOS")
|
||||
exit(0)
|
||||
|
||||
|
||||
def main():
|
||||
global db_manager, chain_monitor
|
||||
|
||||
signal(SIGINT, handle_signals)
|
||||
signal(SIGTERM, handle_signals)
|
||||
signal(SIGQUIT, handle_signals)
|
||||
|
||||
logger.info("Starting TEOS")
|
||||
db_manager = DBManager(config.get("DB_PATH"))
|
||||
|
||||
if not can_connect_to_bitcoind():
|
||||
logger.error("Can't connect to bitcoind. Shutting down")
|
||||
|
||||
elif not in_correct_network(config.get("BTC_NETWORK")):
|
||||
logger.error("bitcoind is running on a different network, check conf.py and bitcoin.conf. Shutting down")
|
||||
|
||||
else:
|
||||
try:
|
||||
secret_key_der = Cryptographer.load_key_file(config.get("TEOS_SECRET_KEY"))
|
||||
if not secret_key_der:
|
||||
raise IOError("TEOS private key can't be loaded")
|
||||
|
||||
watcher = Watcher(db_manager, Responder(db_manager), secret_key_der, config)
|
||||
|
||||
# Create the chain monitor and start monitoring the chain
|
||||
chain_monitor = ChainMonitor(watcher.block_queue, watcher.responder.block_queue)
|
||||
|
||||
watcher_appointments_data = db_manager.load_watcher_appointments()
|
||||
responder_trackers_data = db_manager.load_responder_trackers()
|
||||
|
||||
if len(watcher_appointments_data) == 0 and len(responder_trackers_data) == 0:
|
||||
logger.info("Fresh bootstrap")
|
||||
|
||||
watcher.awake()
|
||||
watcher.responder.awake()
|
||||
|
||||
else:
|
||||
logger.info("Bootstrapping from backed up data")
|
||||
|
||||
# Update the Watcher backed up data if found.
|
||||
if len(watcher_appointments_data) != 0:
|
||||
watcher.appointments, watcher.locator_uuid_map = Builder.build_appointments(
|
||||
watcher_appointments_data
|
||||
)
|
||||
|
||||
# Update the Responder with backed up data if found.
|
||||
if len(responder_trackers_data) != 0:
|
||||
watcher.responder.trackers, watcher.responder.tx_tracker_map = Builder.build_trackers(
|
||||
responder_trackers_data
|
||||
)
|
||||
|
||||
# Awaking components so the states can be updated.
|
||||
watcher.awake()
|
||||
watcher.responder.awake()
|
||||
|
||||
last_block_watcher = db_manager.load_last_block_hash_watcher()
|
||||
last_block_responder = db_manager.load_last_block_hash_responder()
|
||||
|
||||
# Populate the block queues with data if they've missed some while offline. If the blocks of both match
|
||||
# we don't perform the search twice.
|
||||
block_processor = BlockProcessor()
|
||||
|
||||
# FIXME: 32-reorgs-offline dropped txs are not used at this point.
|
||||
last_common_ancestor_watcher, dropped_txs_watcher = block_processor.find_last_common_ancestor(
|
||||
last_block_watcher
|
||||
)
|
||||
missed_blocks_watcher = block_processor.get_missed_blocks(last_common_ancestor_watcher)
|
||||
|
||||
if last_block_watcher == last_block_responder:
|
||||
dropped_txs_responder = dropped_txs_watcher
|
||||
missed_blocks_responder = missed_blocks_watcher
|
||||
|
||||
else:
|
||||
last_common_ancestor_responder, dropped_txs_responder = block_processor.find_last_common_ancestor(
|
||||
last_block_responder
|
||||
)
|
||||
missed_blocks_responder = block_processor.get_missed_blocks(last_common_ancestor_responder)
|
||||
|
||||
# If only one of the instances needs to be updated, it can be done separately.
|
||||
if len(missed_blocks_watcher) == 0 and len(missed_blocks_responder) != 0:
|
||||
Builder.populate_block_queue(watcher.responder.block_queue, missed_blocks_responder)
|
||||
watcher.responder.block_queue.join()
|
||||
|
||||
elif len(missed_blocks_responder) == 0 and len(missed_blocks_watcher) != 0:
|
||||
Builder.populate_block_queue(watcher.block_queue, missed_blocks_watcher)
|
||||
watcher.block_queue.join()
|
||||
|
||||
# Otherwise they need to be updated at the same time, block by block
|
||||
elif len(missed_blocks_responder) != 0 and len(missed_blocks_watcher) != 0:
|
||||
Builder.update_states(watcher, missed_blocks_watcher, missed_blocks_responder)
|
||||
|
||||
# Fire the API and the ChainMonitor
|
||||
# FIXME: 92-block-data-during-bootstrap-db
|
||||
chain_monitor.monitor_chain()
|
||||
API(watcher, config=config).start()
|
||||
except Exception as e:
|
||||
logger.error("An error occurred: {}. Shutting down".format(e))
|
||||
exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
opts, _ = getopt(argv[1:], "", [""])
|
||||
for opt, arg in opts:
|
||||
# FIXME: Leaving this here for future option/arguments
|
||||
pass
|
||||
|
||||
main()
|
||||
68
teos/tools.py
Normal file
68
teos/tools.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from http.client import HTTPException
|
||||
from socket import timeout
|
||||
|
||||
import teos.conf as conf
|
||||
from teos.utils.auth_proxy import AuthServiceProxy, JSONRPCException
|
||||
|
||||
"""
|
||||
Tools is a module with general methods that can used by different entities in the codebase.
|
||||
"""
|
||||
|
||||
|
||||
# NOTCOVERED
|
||||
def bitcoin_cli():
|
||||
"""
|
||||
An ``http`` connection with ``bitcoind`` using the ``json-rpc`` interface.
|
||||
|
||||
Returns:
|
||||
:obj:`AuthServiceProxy <teos.utils.auth_proxy.AuthServiceProxy>`: An authenticated service proxy to ``bitcoind``
|
||||
that can be used to send ``json-rpc`` commands.
|
||||
"""
|
||||
|
||||
return AuthServiceProxy(
|
||||
"http://%s:%s@%s:%d" % (conf.BTC_RPC_USER, conf.BTC_RPC_PASSWD, conf.BTC_RPC_HOST, conf.BTC_RPC_PORT)
|
||||
)
|
||||
|
||||
|
||||
# NOTCOVERED
|
||||
def can_connect_to_bitcoind():
|
||||
"""
|
||||
Checks if the tower has connection to ``bitcoind``.
|
||||
|
||||
Returns:
|
||||
:obj:`bool`: ``True`` if the connection can be established. ``False`` otherwise.
|
||||
"""
|
||||
|
||||
can_connect = True
|
||||
|
||||
try:
|
||||
bitcoin_cli().help()
|
||||
except (timeout, ConnectionRefusedError, JSONRPCException, HTTPException):
|
||||
can_connect = False
|
||||
|
||||
return can_connect
|
||||
|
||||
|
||||
def in_correct_network(network):
|
||||
"""
|
||||
Checks if ``bitcoind`` and the tower are configured to run in the same network (``mainnet``, ``testnet`` or
|
||||
``regtest``)
|
||||
|
||||
Returns:
|
||||
:obj:`bool`: ``True`` if the network configuration matches. ``False`` otherwise.
|
||||
"""
|
||||
|
||||
mainnet_genesis_block_hash = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"
|
||||
testnet3_genesis_block_hash = "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943"
|
||||
correct_network = False
|
||||
|
||||
genesis_block_hash = bitcoin_cli().getblockhash(0)
|
||||
|
||||
if network == "mainnet" and genesis_block_hash == mainnet_genesis_block_hash:
|
||||
correct_network = True
|
||||
elif network == "testnet" and genesis_block_hash == testnet3_genesis_block_hash:
|
||||
correct_network = True
|
||||
elif network == "regtest" and genesis_block_hash not in [mainnet_genesis_block_hash, testnet3_genesis_block_hash]:
|
||||
correct_network = True
|
||||
|
||||
return correct_network
|
||||
0
teos/utils/__init__.py
Normal file
0
teos/utils/__init__.py
Normal file
225
teos/utils/auth_proxy.py
Normal file
225
teos/utils/auth_proxy.py
Normal file
@@ -0,0 +1,225 @@
|
||||
# Copyright (c) 2011 Jeff Garzik
|
||||
#
|
||||
# Previous copyright, from python-jsonrpc/jsonrpc/proxy.py:
|
||||
#
|
||||
# Copyright (c) 2007 Jan-Klaas Kollhof
|
||||
#
|
||||
# This file is part of jsonrpc.
|
||||
#
|
||||
# jsonrpc is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation; either version 2.1 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This software is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with this software; if not, write to the Free Software
|
||||
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
|
||||
"""HTTP proxy for opening RPC connection to bitcoind.
|
||||
|
||||
AuthServiceProxy has the following improvements over python-jsonrpc's
|
||||
ServiceProxy class:
|
||||
|
||||
- HTTP connections persist for the life of the AuthServiceProxy object
|
||||
(if server supports HTTP/1.1)
|
||||
- sends protocol 'version', per JSON-RPC 1.1
|
||||
- sends proper, incrementing 'id'
|
||||
- sends Basic HTTP authentication headers
|
||||
- parses all JSON numbers that look like floats as Decimal
|
||||
- uses standard Python json lib
|
||||
"""
|
||||
|
||||
# bitcoin_rpc auth proxy does not handle broken pipes. Using Bitcoin Core's one which is more complete.
|
||||
# Taken from https://github.com/bitcoin/bitcoin/blob/master/test/functional/test_framework/authproxy.py
|
||||
|
||||
import base64
|
||||
import decimal
|
||||
from http import HTTPStatus
|
||||
import http.client
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
HTTP_TIMEOUT = 30
|
||||
USER_AGENT = "AuthServiceProxy/0.1"
|
||||
|
||||
log = logging.getLogger("BitcoinRPC")
|
||||
|
||||
|
||||
class JSONRPCException(Exception):
|
||||
def __init__(self, rpc_error, http_status=None):
|
||||
try:
|
||||
errmsg = "%(message)s (%(code)i)" % rpc_error
|
||||
except (KeyError, TypeError):
|
||||
errmsg = ""
|
||||
super().__init__(errmsg)
|
||||
self.error = rpc_error
|
||||
self.http_status = http_status
|
||||
|
||||
|
||||
def EncodeDecimal(o):
|
||||
if isinstance(o, decimal.Decimal):
|
||||
return str(o)
|
||||
raise TypeError(repr(o) + " is not JSON serializable")
|
||||
|
||||
|
||||
class AuthServiceProxy:
|
||||
__id_count = 0
|
||||
|
||||
# ensure_ascii: escape unicode as \uXXXX, passed to json.dumps
|
||||
def __init__(self, service_url, service_name=None, timeout=HTTP_TIMEOUT, connection=None, ensure_ascii=True):
|
||||
self.__service_url = service_url
|
||||
self._service_name = service_name
|
||||
self.ensure_ascii = ensure_ascii # can be toggled on the fly by tests
|
||||
self.__url = urllib.parse.urlparse(service_url)
|
||||
user = None if self.__url.username is None else self.__url.username.encode("utf8")
|
||||
passwd = None if self.__url.password is None else self.__url.password.encode("utf8")
|
||||
authpair = user + b":" + passwd
|
||||
self.__auth_header = b"Basic " + base64.b64encode(authpair)
|
||||
self.timeout = timeout
|
||||
self._set_conn(connection)
|
||||
|
||||
def __getattr__(self, name):
|
||||
if name.startswith("__") and name.endswith("__"):
|
||||
# Python internal stuff
|
||||
raise AttributeError
|
||||
if self._service_name is not None:
|
||||
name = "%s.%s" % (self._service_name, name)
|
||||
return AuthServiceProxy(self.__service_url, name, connection=self.__conn)
|
||||
|
||||
def _request(self, method, path, postdata):
|
||||
"""
|
||||
Do a HTTP request, with retry if we get disconnected (e.g. due to a timeout).
|
||||
This is a workaround for https://bugs.python.org/issue3566 which is fixed in Python 3.5.
|
||||
"""
|
||||
headers = {
|
||||
"Host": self.__url.hostname,
|
||||
"User-Agent": USER_AGENT,
|
||||
"Authorization": self.__auth_header,
|
||||
"Content-type": "application/json",
|
||||
}
|
||||
if os.name == "nt":
|
||||
# Windows somehow does not like to re-use connections
|
||||
# TODO: Find out why the connection would disconnect occasionally and make it reusable on Windows
|
||||
self._set_conn()
|
||||
try:
|
||||
self.__conn.request(method, path, postdata, headers)
|
||||
return self._get_response()
|
||||
except http.client.BadStatusLine as e:
|
||||
if e.line == "''": # if connection was closed, try again
|
||||
self.__conn.close()
|
||||
self.__conn.request(method, path, postdata, headers)
|
||||
return self._get_response()
|
||||
else:
|
||||
raise
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
# Python 3.5+ raises BrokenPipeError instead of BadStatusLine when the connection was reset
|
||||
# ConnectionResetError happens on FreeBSD with Python 3.4
|
||||
self.__conn.close()
|
||||
self.__conn.request(method, path, postdata, headers)
|
||||
return self._get_response()
|
||||
|
||||
def get_request(self, *args, **argsn):
|
||||
AuthServiceProxy.__id_count += 1
|
||||
|
||||
log.debug(
|
||||
"-{}-> {} {}".format(
|
||||
AuthServiceProxy.__id_count,
|
||||
self._service_name,
|
||||
json.dumps(args or argsn, default=EncodeDecimal, ensure_ascii=self.ensure_ascii),
|
||||
)
|
||||
)
|
||||
if args and argsn:
|
||||
raise ValueError("Cannot handle both named and positional arguments")
|
||||
return {
|
||||
"version": "1.1",
|
||||
"method": self._service_name,
|
||||
"params": args or argsn,
|
||||
"id": AuthServiceProxy.__id_count,
|
||||
}
|
||||
|
||||
def __call__(self, *args, **argsn):
|
||||
postdata = json.dumps(self.get_request(*args, **argsn), default=EncodeDecimal, ensure_ascii=self.ensure_ascii)
|
||||
response, status = self._request("POST", self.__url.path, postdata.encode("utf-8"))
|
||||
if response["error"] is not None:
|
||||
raise JSONRPCException(response["error"], status)
|
||||
elif "result" not in response:
|
||||
raise JSONRPCException({"code": -343, "message": "missing JSON-RPC result"}, status)
|
||||
elif status != HTTPStatus.OK:
|
||||
raise JSONRPCException({"code": -342, "message": "non-200 HTTP status code but no JSON-RPC error"}, status)
|
||||
else:
|
||||
return response["result"]
|
||||
|
||||
def batch(self, rpc_call_list):
|
||||
postdata = json.dumps(list(rpc_call_list), default=EncodeDecimal, ensure_ascii=self.ensure_ascii)
|
||||
log.debug("--> " + postdata)
|
||||
response, status = self._request("POST", self.__url.path, postdata.encode("utf-8"))
|
||||
if status != HTTPStatus.OK:
|
||||
raise JSONRPCException({"code": -342, "message": "non-200 HTTP status code but no JSON-RPC error"}, status)
|
||||
return response
|
||||
|
||||
def _get_response(self):
|
||||
req_start_time = time.time()
|
||||
try:
|
||||
http_response = self.__conn.getresponse()
|
||||
except socket.timeout:
|
||||
raise JSONRPCException(
|
||||
{
|
||||
"code": -344,
|
||||
"message": "%r RPC took longer than %f seconds. Consider "
|
||||
"using larger timeout for calls that take "
|
||||
"longer to return." % (self._service_name, self.__conn.timeout),
|
||||
}
|
||||
)
|
||||
if http_response is None:
|
||||
raise JSONRPCException({"code": -342, "message": "missing HTTP response from server"})
|
||||
|
||||
content_type = http_response.getheader("Content-Type")
|
||||
if content_type != "application/json":
|
||||
raise JSONRPCException(
|
||||
{
|
||||
"code": -342,
|
||||
"message": "non-JSON HTTP response with '%i %s' from server"
|
||||
% (http_response.status, http_response.reason),
|
||||
},
|
||||
http_response.status,
|
||||
)
|
||||
|
||||
responsedata = http_response.read().decode("utf8")
|
||||
response = json.loads(responsedata, parse_float=decimal.Decimal)
|
||||
elapsed = time.time() - req_start_time
|
||||
if "error" in response and response["error"] is None:
|
||||
log.debug(
|
||||
"<-%s- [%.6f] %s"
|
||||
% (
|
||||
response["id"],
|
||||
elapsed,
|
||||
json.dumps(response["result"], default=EncodeDecimal, ensure_ascii=self.ensure_ascii),
|
||||
)
|
||||
)
|
||||
else:
|
||||
log.debug("<-- [%.6f] %s" % (elapsed, responsedata))
|
||||
return response, http_response.status
|
||||
|
||||
def __truediv__(self, relative_uri):
|
||||
return AuthServiceProxy(
|
||||
"{}/{}".format(self.__service_url, relative_uri), self._service_name, connection=self.__conn
|
||||
)
|
||||
|
||||
def _set_conn(self, connection=None):
|
||||
port = 80 if self.__url.port is None else self.__url.port
|
||||
if connection:
|
||||
self.__conn = connection
|
||||
self.timeout = connection.timeout
|
||||
elif self.__url.scheme == "https":
|
||||
self.__conn = http.client.HTTPSConnection(self.__url.hostname, port, timeout=self.timeout)
|
||||
else:
|
||||
self.__conn = http.client.HTTPConnection(self.__url.hostname, port, timeout=self.timeout)
|
||||
286
teos/watcher.py
Normal file
286
teos/watcher.py
Normal file
@@ -0,0 +1,286 @@
|
||||
from uuid import uuid4
|
||||
from queue import Queue
|
||||
from threading import Thread
|
||||
|
||||
import common.cryptographer
|
||||
from common.cryptographer import Cryptographer
|
||||
from common.appointment import Appointment
|
||||
from common.tools import compute_locator
|
||||
|
||||
from common.logger import Logger
|
||||
|
||||
from teos import LOG_PREFIX
|
||||
from teos.cleaner import Cleaner
|
||||
from teos.block_processor import BlockProcessor
|
||||
|
||||
logger = Logger(actor="Watcher", log_name_prefix=LOG_PREFIX)
|
||||
common.cryptographer.logger = Logger(actor="Cryptographer", log_name_prefix=LOG_PREFIX)
|
||||
|
||||
|
||||
class Watcher:
|
||||
"""
|
||||
The :class:`Watcher` is the class in charge to watch for channel breaches for the appointments accepted by the
|
||||
tower.
|
||||
|
||||
The :class:`Watcher` keeps track of the accepted appointments in ``appointments`` and, for new received block,
|
||||
checks if any breach has happened by comparing the txids with the appointment locators. If a breach is seen, the
|
||||
:obj:`EncryptedBlob <common.encrypted_blob.EncryptedBlob>` of the corresponding appointment is decrypted and the data
|
||||
is passed to the :obj:`Responder <teos.responder.Responder>`.
|
||||
|
||||
If an appointment reaches its end with no breach, the data is simply deleted.
|
||||
|
||||
The :class:`Watcher` receives information about new received blocks via the ``block_queue`` that is populated by the
|
||||
:obj:`ChainMonitor <teos.chain_monitor.ChainMonitor>`.
|
||||
|
||||
Args:
|
||||
db_manager (:obj:`DBManager <teos.db_manager>`): a ``DBManager`` instance to interact with the database.
|
||||
sk_der (:obj:`bytes`): a DER encoded private key used to sign appointment receipts (signaling acceptance).
|
||||
config (:obj:`dict`): a dictionary containing all the configuration parameters. Used locally to retrieve
|
||||
``MAX_APPOINTMENTS`` and ``EXPIRY_DELTA``.
|
||||
responder (:obj:`Responder <teos.responder.Responder>`): a ``Responder`` instance.
|
||||
|
||||
|
||||
Attributes:
|
||||
appointments (:obj:`dict`): a dictionary containing a simplification of the appointments (:obj:`Appointment
|
||||
<teos.appointment.Appointment>` instances) accepted by the tower (``locator`` and ``end_time``).
|
||||
It's populated trough ``add_appointment``.
|
||||
locator_uuid_map (:obj:`dict`): a ``locator:uuid`` map used to allow the :obj:`Watcher` to deal with several
|
||||
appointments with the same ``locator``.
|
||||
block_queue (:obj:`Queue`): A queue used by the :obj:`Watcher` to receive block hashes from ``bitcoind``. It is
|
||||
populated by the :obj:`ChainMonitor <teos.chain_monitor.ChainMonitor>`.
|
||||
config (:obj:`dict`): a dictionary containing all the configuration parameters. Used locally to retrieve
|
||||
``MAX_APPOINTMENTS`` and ``EXPIRY_DELTA``.
|
||||
db_manager (:obj:`DBManager <teos.db_manager>`): A db manager instance to interact with the database.
|
||||
signing_key (:mod:`PrivateKey`): a private key used to sign accepted appointments.
|
||||
|
||||
Raises:
|
||||
ValueError: if `teos_sk_file` is not found.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, db_manager, responder, sk_der, config):
|
||||
self.appointments = dict()
|
||||
self.locator_uuid_map = dict()
|
||||
self.block_queue = Queue()
|
||||
self.config = config
|
||||
self.db_manager = db_manager
|
||||
self.responder = responder
|
||||
self.signing_key = Cryptographer.load_private_key_der(sk_der)
|
||||
|
||||
def awake(self):
|
||||
watcher_thread = Thread(target=self.do_watch, daemon=True)
|
||||
watcher_thread.start()
|
||||
|
||||
return watcher_thread
|
||||
|
||||
def add_appointment(self, appointment):
|
||||
"""
|
||||
Adds a new appointment to the ``appointments`` dictionary if ``max_appointments`` has not been reached.
|
||||
|
||||
``add_appointment`` is the entry point of the Watcher. Upon receiving a new appointment it will start monitoring
|
||||
the blockchain (``do_watch``) until ``appointments`` is empty.
|
||||
|
||||
Once a breach is seen on the blockchain, the :obj:`Watcher` will decrypt the corresponding
|
||||
:obj:`EncryptedBlob <common.encrypted_blob.EncryptedBlob>` and pass the information to the
|
||||
:obj:`Responder <teos.responder.Responder>`.
|
||||
|
||||
The tower may store multiple appointments with the same ``locator`` to avoid DoS attacks based on data
|
||||
rewriting. `locators`` should be derived from the ``dispute_txid``, but that task is performed by the user, and
|
||||
the tower has no way of verifying whether or not they have been properly derived. Therefore, appointments are
|
||||
identified by ``uuid`` and stored in ``appointments`` and ``locator_uuid_map``.
|
||||
|
||||
Args:
|
||||
appointment (:obj:`Appointment <teos.appointment.Appointment>`): the appointment to be added to the
|
||||
:obj:`Watcher`.
|
||||
|
||||
Returns:
|
||||
:obj:`tuple`: A tuple signaling if the appointment has been added or not (based on ``max_appointments``).
|
||||
The structure looks as follows:
|
||||
|
||||
- ``(True, signature)`` if the appointment has been accepted.
|
||||
- ``(False, None)`` otherwise.
|
||||
|
||||
"""
|
||||
|
||||
if len(self.appointments) < self.config.get("MAX_APPOINTMENTS"):
|
||||
|
||||
uuid = uuid4().hex
|
||||
self.appointments[uuid] = {"locator": appointment.locator, "end_time": appointment.end_time}
|
||||
|
||||
if appointment.locator in self.locator_uuid_map:
|
||||
self.locator_uuid_map[appointment.locator].append(uuid)
|
||||
|
||||
else:
|
||||
self.locator_uuid_map[appointment.locator] = [uuid]
|
||||
|
||||
self.db_manager.store_watcher_appointment(uuid, appointment.to_json())
|
||||
self.db_manager.create_append_locator_map(appointment.locator, uuid)
|
||||
|
||||
appointment_added = True
|
||||
signature = Cryptographer.sign(appointment.serialize(), self.signing_key)
|
||||
|
||||
logger.info("New appointment accepted", locator=appointment.locator)
|
||||
|
||||
else:
|
||||
appointment_added = False
|
||||
signature = None
|
||||
|
||||
logger.info("Maximum appointments reached, appointment rejected", locator=appointment.locator)
|
||||
|
||||
return appointment_added, signature
|
||||
|
||||
def do_watch(self):
|
||||
"""
|
||||
Monitors the blockchain whilst there are pending appointments.
|
||||
|
||||
This is the main method of the :obj:`Watcher` and the one in charge to pass appointments to the
|
||||
:obj:`Responder <teos.responder.Responder>` upon detecting a breach.
|
||||
"""
|
||||
|
||||
while True:
|
||||
block_hash = self.block_queue.get()
|
||||
block = BlockProcessor.get_block(block_hash)
|
||||
logger.info("New block received", block_hash=block_hash, prev_block_hash=block.get("previousblockhash"))
|
||||
|
||||
if len(self.appointments) > 0 and block is not None:
|
||||
txids = block.get("tx")
|
||||
|
||||
expired_appointments = [
|
||||
uuid
|
||||
for uuid, appointment_data in self.appointments.items()
|
||||
if block["height"] > appointment_data.get("end_time") + self.config.get("EXPIRY_DELTA")
|
||||
]
|
||||
|
||||
Cleaner.delete_expired_appointments(
|
||||
expired_appointments, self.appointments, self.locator_uuid_map, self.db_manager
|
||||
)
|
||||
|
||||
valid_breaches, invalid_breaches = self.filter_valid_breaches(self.get_breaches(txids))
|
||||
|
||||
triggered_flags = []
|
||||
appointments_to_delete = []
|
||||
|
||||
for uuid, breach in valid_breaches.items():
|
||||
logger.info(
|
||||
"Notifying responder and deleting appointment",
|
||||
penalty_txid=breach["penalty_txid"],
|
||||
locator=breach["locator"],
|
||||
uuid=uuid,
|
||||
)
|
||||
|
||||
receipt = self.responder.handle_breach(
|
||||
uuid,
|
||||
breach["locator"],
|
||||
breach["dispute_txid"],
|
||||
breach["penalty_txid"],
|
||||
breach["penalty_rawtx"],
|
||||
self.appointments[uuid].get("end_time"),
|
||||
block_hash,
|
||||
)
|
||||
|
||||
# FIXME: Only necessary because of the triggered appointment approach. Fix if it changes.
|
||||
|
||||
if receipt.delivered:
|
||||
Cleaner.delete_appointment_from_memory(uuid, self.appointments, self.locator_uuid_map)
|
||||
triggered_flags.append(uuid)
|
||||
else:
|
||||
appointments_to_delete.append(uuid)
|
||||
|
||||
# Appointments are only flagged as triggered if they are delivered, otherwise they are just deleted.
|
||||
appointments_to_delete.extend(invalid_breaches)
|
||||
self.db_manager.batch_create_triggered_appointment_flag(triggered_flags)
|
||||
|
||||
Cleaner.delete_completed_appointments(
|
||||
appointments_to_delete, self.appointments, self.locator_uuid_map, self.db_manager
|
||||
)
|
||||
|
||||
if len(self.appointments) is 0:
|
||||
logger.info("No more pending appointments")
|
||||
|
||||
# Register the last processed block for the watcher
|
||||
self.db_manager.store_last_block_hash_watcher(block_hash)
|
||||
self.block_queue.task_done()
|
||||
|
||||
def get_breaches(self, txids):
|
||||
"""
|
||||
Gets a list of channel breaches given the list of transaction ids.
|
||||
|
||||
Args:
|
||||
txids (:obj:`list`): the list of transaction ids included in the last received block.
|
||||
|
||||
Returns:
|
||||
:obj:`dict`: A dictionary (``locator:txid``) with all the breaches found. An empty dictionary if none are
|
||||
found.
|
||||
"""
|
||||
|
||||
potential_locators = {compute_locator(txid): txid for txid in txids}
|
||||
|
||||
# Check is any of the tx_ids in the received block is an actual match
|
||||
intersection = set(self.locator_uuid_map.keys()).intersection(potential_locators.keys())
|
||||
breaches = {locator: potential_locators[locator] for locator in intersection}
|
||||
|
||||
if len(breaches) > 0:
|
||||
logger.info("List of breaches", breaches=breaches)
|
||||
|
||||
else:
|
||||
logger.info("No breaches found")
|
||||
|
||||
return breaches
|
||||
|
||||
def filter_valid_breaches(self, breaches):
|
||||
"""
|
||||
Filters what of the found breaches contain valid transaction data.
|
||||
|
||||
The :obj:`Watcher` cannot if a given :obj:`EncryptedBlob <common.encrypted_blob.EncryptedBlob>` contains a valid
|
||||
transaction until a breach if seen. Blobs that contain arbitrary data are dropped and not sent to the
|
||||
:obj:`Responder <teos.responder.Responder>`.
|
||||
|
||||
Args:
|
||||
breaches (:obj:`dict`): a dictionary containing channel breaches (``locator:txid``).
|
||||
|
||||
Returns:
|
||||
:obj:`dict`: A dictionary containing all the breaches flagged either as valid or invalid.
|
||||
The structure is as follows:
|
||||
|
||||
``{locator, dispute_txid, penalty_txid, penalty_rawtx, valid_breach}``
|
||||
"""
|
||||
|
||||
valid_breaches = {}
|
||||
invalid_breaches = []
|
||||
|
||||
# A cache of the already decrypted blobs so replicate decryption can be avoided
|
||||
decrypted_blobs = {}
|
||||
|
||||
for locator, dispute_txid in breaches.items():
|
||||
for uuid in self.locator_uuid_map[locator]:
|
||||
appointment = Appointment.from_dict(self.db_manager.load_watcher_appointment(uuid))
|
||||
|
||||
if appointment.encrypted_blob.data in decrypted_blobs:
|
||||
penalty_tx, penalty_rawtx = decrypted_blobs[appointment.encrypted_blob.data]
|
||||
|
||||
else:
|
||||
try:
|
||||
penalty_rawtx = Cryptographer.decrypt(appointment.encrypted_blob, dispute_txid)
|
||||
|
||||
except ValueError:
|
||||
penalty_rawtx = None
|
||||
|
||||
penalty_tx = BlockProcessor.decode_raw_transaction(penalty_rawtx)
|
||||
decrypted_blobs[appointment.encrypted_blob.data] = (penalty_tx, penalty_rawtx)
|
||||
|
||||
if penalty_tx is not None:
|
||||
valid_breaches[uuid] = {
|
||||
"locator": locator,
|
||||
"dispute_txid": dispute_txid,
|
||||
"penalty_txid": penalty_tx.get("txid"),
|
||||
"penalty_rawtx": penalty_rawtx,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Breach found for locator", locator=locator, uuid=uuid, penalty_txid=penalty_tx.get("txid")
|
||||
)
|
||||
|
||||
else:
|
||||
invalid_breaches.append(uuid)
|
||||
|
||||
return valid_breaches, invalid_breaches
|
||||
Reference in New Issue
Block a user