Skip to content

Commit

Permalink
Use Esplora for fee estimation and tx broadcast for blocksonly nodes
Browse files Browse the repository at this point in the history
  • Loading branch information
kristapsk committed Jul 27, 2023
1 parent 54db582 commit 49b3ab0
Show file tree
Hide file tree
Showing 3 changed files with 118 additions and 4 deletions.
31 changes: 28 additions & 3 deletions jmclient/jmclient/blockchaininterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from jmbase.support import get_log, jmprint, EXIT_FAILURE
from jmclient.configure import jm_single
from jmclient.jsonrpc import JsonRpcConnectionError, JsonRpcError
from jmclient.esplora_api_client import EsploraApiClient


# an inaccessible blockheight; consider rewriting in 1900 years
Expand Down Expand Up @@ -185,6 +186,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

Expand Down Expand Up @@ -402,6 +408,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])
Expand Down Expand Up @@ -478,7 +491,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
Expand All @@ -489,9 +506,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):
try:
Expand Down
88 changes: 88 additions & 0 deletions jmclient/jmclient/esplora_api_client.py
Original file line number Diff line number Diff line change
@@ -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)

3 changes: 2 additions & 1 deletion jmclient/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
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', 'werkzeug==2.2.3'],
'autobahn==20.12.3', 'werkzeug==2.2.3',
'pysocks==1.7.1'],
python_requires='>=3.6',
zip_safe=False)

0 comments on commit 49b3ab0

Please sign in to comment.