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 Apr 27, 2023
1 parent a5eb019 commit 9740212
Show file tree
Hide file tree
Showing 3 changed files with 121 additions and 8 deletions.
39 changes: 32 additions & 7 deletions jmclient/jmclient/blockchaininterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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
Expand All @@ -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: " +
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)

2 changes: 1 addition & 1 deletion jmclient/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

0 comments on commit 9740212

Please sign in to comment.