From 70f81aeb41f2c6b15501d73a0fd9ae63ac234bee Mon Sep 17 00:00:00 2001 From: Kristaps Kaupe Date: Wed, 26 Apr 2023 16:50:39 +0300 Subject: [PATCH] Use Esplora for fee estimation and tx broadcast for blocksonly nodes --- jmclient/jmclient/blockchaininterface.py | 31 ++++++++- jmclient/jmclient/esplora_api_client.py | 88 ++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 jmclient/jmclient/esplora_api_client.py diff --git a/jmclient/jmclient/blockchaininterface.py b/jmclient/jmclient/blockchaininterface.py index 67a5d5bc6..b90ce938d 100644 --- a/jmclient/jmclient/blockchaininterface.py +++ b/jmclient/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 @@ -348,6 +349,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 @@ -514,6 +520,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]) @@ -569,7 +582,11 @@ def _estimate_fee_basic(self, conf_target: int) -> Optional[int]: # 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 @@ -580,9 +597,17 @@ def _estimate_fee_basic(self, conf_target: int) -> Optional[int]: # the 'feerate' key is found and contains a positive value: if estimate and estimate > 0: return btc.btc_to_sat(estimate) + # 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/jmclient/jmclient/esplora_api_client.py b/jmclient/jmclient/esplora_api_client.py new file mode 100644 index 000000000..973004543 --- /dev/null +++ b/jmclient/jmclient/esplora_api_client.py @@ -0,0 +1,88 @@ +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: + 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("Esplora API not available for signet.") + 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) +