From 6fc31909d1755e949afba665d4876ee5ef77eb54 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 | 39 +++++++++-- jmclient/jmclient/esplora_api_client.py | 88 ++++++++++++++++++++++++ jmclient/setup.py | 2 +- 3 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 jmclient/jmclient/esplora_api_client.py diff --git a/jmclient/jmclient/blockchaininterface.py b/jmclient/jmclient/blockchaininterface.py index 9637e7543..e76393b99 100644 --- a/jmclient/jmclient/blockchaininterface.py +++ b/jmclient/jmclient/blockchaininterface.py @@ -13,6 +13,7 @@ from jmclient.jsonrpc import JsonRpcConnectionError, JsonRpcError from jmclient.configure import jm_single +from jmclient.esplora_api_client import EsploraApiClient from jmbase.support import get_log, jmprint, EXIT_FAILURE @@ -107,6 +108,11 @@ def __init__(self, jsonRpc, network, wallet_name): "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): return len(self._rpc('getaddressinfo', [addr])['labels']) > 0 @@ -315,6 +321,13 @@ def pushtx(self, txbin): """ 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]) @@ -426,7 +439,11 @@ def estimate_fee_per_kb(self, N): tries = 2 if N == 1 else 1 for i in range(tries): - rpc_result = self._rpc('estimatesmartfee', [N + i]) + try: + rpc_result = self._rpc('estimatesmartfee', [N + 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 @@ -442,12 +459,20 @@ def estimate_fee_per_kb(self, N): estimate_in_sat * float(1 + tx_fees_factor)) break else: # cannot get a valid estimate after `tries` tries: - fallback_fee = 10000 - retval = random.uniform(fallback_fee * float(1 - tx_fees_factor), - fallback_fee * float(1 + tx_fees_factor)) - log.warn("Could not source a fee estimate from Core, " + - "falling back to default: " + - btc.fee_per_kb_to_str(fallback_fee) + ".") + + # Try Esplora (Blockstream) as a fallback + esplora_fee = self.esplora_api_client.estimate_fee_basic(N) + if esplora_fee: + retval = random.uniform(esplora_fee * float(1 - tx_fees_factor), + esplora_fee * float(1 + tx_fees_factor)) + log.info("Local fee estimation failed, using one from Esplora API.") + else: + fallback_fee = 10000 + retval = random.uniform(fallback_fee * float(1 - tx_fees_factor), + fallback_fee * float(1 + tx_fees_factor)) + log.warn("Could not source a fee estimate from Core, " + + "falling back to default: " + + btc.fee_per_kb_to_str(fallback_fee) + ".") if retval < mempoolminfee_in_sat: log.info("Using this mempool min fee as tx feerate: " + diff --git a/jmclient/jmclient/esplora_api_client.py b/jmclient/jmclient/esplora_api_client.py new file mode 100644 index 000000000..c5a48b0d8 --- /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 __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) + diff --git a/jmclient/setup.py b/jmclient/setup.py index 43327d84d..3efc1ec8e 100644 --- a/jmclient/setup.py +++ b/jmclient/setup.py @@ -12,6 +12,6 @@ install_requires=['joinmarketbase==0.9.10dev', 'mnemonic==0.20', 'argon2_cffi==21.3.0', 'bencoder.pyx==3.0.1', 'pyaes==1.6.1', 'klein==20.6.0', 'pyjwt==2.4.0', - 'autobahn==20.12.3'], + 'autobahn==20.12.3', 'pysocks==1.7.1'], python_requires='>=3.6', zip_safe=False)