diff --git a/pyproject.toml b/pyproject.toml index 5f1700148..5e4394141 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ jmclient = [ "klein==20.6.0", "mnemonic==0.20", "pyjwt==2.4.0", + "PySocks==1.7.1", "werkzeug==2.2.3", ] jmdaemon = [ diff --git a/src/jmclient/blockchaininterface.py b/src/jmclient/blockchaininterface.py index 42b09bbcb..2cc019b35 100644 --- a/src/jmclient/blockchaininterface.py +++ b/src/jmclient/blockchaininterface.py @@ -14,6 +14,7 @@ from jmbase.support import get_log, jmprint, EXIT_FAILURE from jmclient.configure import jm_single from jmclient.jsonrpc import JsonRpc, JsonRpcConnectionError, JsonRpcError +from jmclient.esplora_api_client import EsploraApiClient # an inaccessible blockheight; consider rewriting in 1900 years @@ -365,6 +366,11 @@ def __init__(self, jsonRpc: JsonRpc, network: str, wallet_name: str) -> None: "setting in joinmarket.cfg) instead. See docs/USAGE.md " "for details.") + self.no_local_mempool = not self._rpc("getnetworkinfo", [])["localrelay"] + if self.no_local_mempool: + log.debug("Bitcoin Core running in blocksonly mode.") + self.esplora_api_client = EsploraApiClient() + def is_address_imported(self, addr: str) -> bool: return len(self._rpc('getaddressinfo', [addr])['labels']) > 0 @@ -531,6 +537,13 @@ def pushtx(self, txbin: bytes) -> bool: """ Given a binary serialized valid bitcoin transaction, broadcasts it to the network. """ + # If don't have local mempool, try pushing tx using Blockstream + # Esplora API first for privacy reasons. + if self.no_local_mempool: + result = self.esplora_api_client.pushtx(txbin) + if result: + return result + txhex = bintohex(txbin) try: txid = self._rpc('sendrawtransaction', [txhex]) @@ -594,7 +607,11 @@ def _estimate_fee_basic(self, # should be used instead of falling back to hardcoded values tries = 2 if conf_target == 1 else 1 for i in range(tries): - rpc_result = self._rpc('estimatesmartfee', [conf_target + i]) + try: + rpc_result = self._rpc('estimatesmartfee', [conf_target + i]) + except JsonRpcError: + # Handle jmclient.jsonrpc.JsonRpcError: {'code': -32603, 'message': 'Fee estimation disabled'} + continue if not rpc_result: # in case of connection error: return None @@ -605,9 +622,17 @@ def _estimate_fee_basic(self, # the 'feerate' key is found and contains a positive value: if estimate and estimate > 0: return (btc.btc_to_sat(estimate), rpc_result.get('blocks')) + # cannot get a valid estimate after `tries` tries: - log.warn("Could not source a fee estimate from Core") - return None + log.info("Could not source a fee estimate from Core") + # Try Esplora (Blockstream) as a fallback + esplora_fee = self.esplora_api_client.estimate_fee_basic(conf_target) + if esplora_fee: + log.info("Local fee estimation failed, using one from Esplora API.") + return esplora_fee + else: + log.warn("Could not source a fee estimate neither from Core nor Esplora API.") + return None def get_current_block_height(self) -> int: try: diff --git a/src/jmclient/esplora_api_client.py b/src/jmclient/esplora_api_client.py new file mode 100644 index 000000000..2445af98d --- /dev/null +++ b/src/jmclient/esplora_api_client.py @@ -0,0 +1,89 @@ +import collections +import json +import requests +from math import ceil +from typing import Optional + +from jmbase import bintohex, get_log +from jmclient.configure import jm_single + + +jlog = get_log() + + +class EsploraApiClient(): + + _API_URL_BASE_MAINNET = "http://explorerzydxu5ecjrkwceayqybizmpjjznk5izmitf2modhcusuqlid.onion/api/" + _API_URL_BASE_TESTNET = "http://explorerzydxu5ecjrkwceayqybizmpjjznk5izmitf2modhcusuqlid.onion/testnet/api/" + + def __init__(self, api_base_url: Optional[str] = None) -> None: + jcg = jm_single().config.get + if api_base_url: + self.api_base_url = api_base_url + else: + self.api_base_url = None + network = jcg("BLOCKCHAIN", "network") + if network == "mainnet": + self.api_base_url = self._API_URL_BASE_MAINNET + elif network == "testnet": + if jcg("BLOCKCHAIN", "blockchain_source") != "regtest": + self.api_base_url = self._API_URL_BASE_TESTNET + else: + return + else: + jlog.debug(f"Esplora API not available for {network}.") + return + jlog.debug("Esplora API will use {} backend.".format(self.api_base_url)) + onion_socks5_host = jcg("PAYJOIN", "onion_socks5_host") + onion_socks5_port = jcg("PAYJOIN", "onion_socks5_port") + self.session = requests.session() + self.proxies = { + "http": "socks5h://" + + onion_socks5_host + ":" + onion_socks5_port, + "https": "socks5h://" + + onion_socks5_host + ":" + onion_socks5_port + } + + def _do_request(self, uri: str, body: Optional[str] = None) -> bytes: + url = self.api_base_url + uri + jlog.debug("Doing request to " + url) + if body: + response = self.session.post(url, data=body, proxies=self.proxies) + else: + response = self.session.get(url, proxies=self.proxies) + jlog.debug(str(response.content)) + return response.content + + def pushtx(self, txbin: bytes) -> bool: + if not self.api_base_url: + return False + txhex = bintohex(txbin) + txid = self._do_request("tx", txhex) + return True if len(txid) == 64 else False + + def estimate_fee_basic(self, conf_target: int) -> Optional[int]: + if not self.api_base_url: + return None + try: + estimates = json.loads(self._do_request("fee-estimates")) + estimates = { int(k):v for k,v in estimates.items() } + except Exception as e: + jlog.debug(e) + return None + sorted_estimates = collections.OrderedDict(sorted(estimates.items())) + prev = None + for k, v in sorted_estimates.items(): + if k > conf_target: + break + prev = v + return ceil(prev * 1000) if prev else None + +if __name__ == "__main__": + from jmclient import load_program_config + load_program_config() + ec = EsploraApiClient() + est = ec.estimate_fee_basic(3) + print(est) + est = ec.estimate_fee_basic(999) + print(est) +