pisa -> teos

This commit is contained in:
Sergi Delgado Segura
2020-03-17 13:05:55 +01:00
parent 15126618d5
commit 7c7ff909d7
56 changed files with 373 additions and 372 deletions

38
teos/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View File

@@ -0,0 +1,3 @@
pytest
black
bitcoind_mock===0.0.4

7
teos/requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
zmq
flask
cryptography
coincurve
pyzbase32
requests
plyvel

488
teos/responder.py Normal file
View 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
View 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
View 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
View 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
View 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
View File

225
teos/utils/auth_proxy.py Normal file
View 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
View 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