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 Feb 13, 2024
1 parent 6b9a210 commit 8848c82
Show file tree
Hide file tree
Showing 3 changed files with 118 additions and 3 deletions.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
31 changes: 28 additions & 3 deletions src/jmclient/blockchaininterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
89 changes: 89 additions & 0 deletions src/jmclient/esplora_api_client.py
Original file line number Diff line number Diff line change
@@ -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)

0 comments on commit 8848c82

Please sign in to comment.