from collections import defaultdict
import socket
from threading import Lock
import traceback
import asyncio
import logging
import ssl
from typing import Optional, Union, Tuple, List, Dict
from asyncio import AbstractEventLoop
from .httpprotocol import HTTPProtocol
from .wsprotocol import WSProtocol
from .protocol import StratumProtocol
from .exc import ElectrumErrorResponse
logger = logging.getLogger(__name__)
class StratumClient:
warn_on_connection_loss: bool
protocol: Optional[Union[StratumProtocol, WSProtocol]]
lock: Lock
next_id: int
inflight: Dict[str, Tuple[str, asyncio.Future]]
subscriptions: Dict[str, List[asyncio.Queue]]
ka_task: Optional[asyncio.Task]
loop: AbstractEventLoop
def __init__(
self, loop: Optional[AbstractEventLoop] = None, warn_on_connection_loss=True
):
self.warn_on_connection_loss = warn_on_connection_loss
self.protocol = None
self.lock = Lock()
self.next_id = 1
self.inflight = defaultdict(tuple)
self.subscriptions = defaultdict(list)
self.ka_task = None
self.loop = loop or asyncio.get_event_loop()
def connection_lost(self, _protocol):
self.protocol = None
if self.warn_on_connection_loss:
logger.warning(
"Electrum server connection lost. Traceback: %s", traceback.format_exc()
)
if self.ka_task:
self.ka_task.cancel()
self.ka_task = None
def close(self):
if self.protocol:
self.protocol.close()
self.protocol = None
if self.ka_task:
self.ka_task.cancel()
self.ka_task = None
async def connect(self, server_info, proto_code=None):
assert not self.protocol, "already connected"
if not proto_code:
proto_code, *_ = server_info.protocols
logger.debug("Connecting to: %r", server_info)
hostname, port, use_ssl = server_info.get_port(proto_code)
if proto_code == "g":
protocol = WSProtocol(f"ws://{hostname}:{port}", self, self.loop)
await protocol.connect()
self.protocol = protocol
elif proto_code == "w":
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
protocol = WSProtocol(
f"wss://{hostname}:{port}", self, self.loop, ssl_context
)
await protocol.connect()
self.protocol = protocol
elif proto_code == "h":
protocol = HTTPProtocol(hostname, port, self, self.loop)
await protocol.connect()
self.protocol = protocol
elif proto_code == "H":
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
protocol = HTTPProtocol(
hostname, port, self, self.loop, use_ssl=True, ssl_context=ssl_context
)
await protocol.connect()
self.protocol = protocol
else:
if use_ssl:
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
_transport, protocol = await self.loop.create_connection(
StratumProtocol, host=hostname, port=port, ssl=ssl_context
)
else:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setblocking(False)
await self.loop.sock_connect(sock, (hostname, port))
_transport, protocol = await self.loop.create_connection(
StratumProtocol, sock=sock
)
self.protocol = protocol
protocol.client = self
self.ka_task = self.loop.create_task(self._keepalive())
logger.debug("Connected to: %r", server_info)
return
async def _keepalive(self):
while self.protocol:
await self.RPC("server.ping")
await asyncio.sleep(10)
def _send_request(self, method, params=None, is_subscribe=False):
with self.lock:
return self._send_request_inner(method, params, is_subscribe)
def _send_request_inner(self, method, params=None, is_subscribe=False):
if params is None:
params = []
self.next_id += 1
req_id = self.next_id
msg = {"id": req_id, "method": method, "params": params}
if is_subscribe:
wait_q = asyncio.Queue()
self.subscriptions[method].append(wait_q)
fut = asyncio.Future(loop=self.loop)
self.inflight[req_id] = (msg, fut)
assert self.protocol
self.protocol.send_data(msg)
return fut if not is_subscribe else (fut, wait_q)
def _got_response(self, msg):
with self.lock:
return self._got_response_inner(msg)
def _got_response_inner(self, msg):
resp_id = msg.get("id", None)
if resp_id is None:
method = msg.get("method", None)
if not method:
logger.error(
"Incoming server message had no ID nor method in it %s", msg
)
return
result = msg.get("params", None)
logger.debug("Traffic on subscription: %s", method)
subs = self.subscriptions.get(method)
for queue in subs:
self.loop.create_task(queue.put(result))
return
assert "method" not in msg
result = msg.get("result")
inf = self.inflight.pop(resp_id)
if not inf:
logger.error("Incoming server message had unknown ID in it: %s", resp_id)
return
req, retval = inf
if retval.done() or retval.cancelled():
logger.info("Future already done for resp_id %s: %s", resp_id, msg)
return
if "error" in msg:
err = msg["error"]
logger.info("Error response: '%s'", err)
retval.set_exception(ElectrumErrorResponse(err, req))
else:
retval.set_result(result)
def RPC(self, method: str, *params):
assert "." in method
return self._send_request(method, params)
def subscribe(self, method: str, *params):
assert "." in method
assert method.endswith("subscribe")
return self._send_request(method, params, is_subscribe=True)