diff --git a/sauron/sauron.py b/sauron/sauron.py index 3e2e34c9a..7c90b47eb 100755 --- a/sauron/sauron.py +++ b/sauron/sauron.py @@ -2,27 +2,23 @@ import requests import sys import time +from pprint import pprint -from requests.packages.urllib3.util.retry import Retry +from urllib3.util.retry import Retry from requests.adapters import HTTPAdapter from art import sauron_eye from pyln.client import Plugin - plugin = Plugin(dynamic=False) plugin.sauron_socks_proxies = None plugin.sauron_network = "test" - class SauronError(Exception): pass - def fetch(url): - """Fetch this {url}, maybe through a pre-defined proxy.""" + """Fetch the given {url}, maybe through a pre-defined proxy.""" # FIXME: Maybe try to be smart and renew circuit to broadcast different - # transactions ? Hint: lightningd will agressively send us the same - # transaction a certain amount of times. session = requests.session() session.proxies = plugin.sauron_socks_proxies retry_strategy = Retry( @@ -38,11 +34,12 @@ def fetch(url): return session.get(url) - @plugin.init() -def init(plugin, options, configuration, **kwargs): - plugin.api_endpoint = options["sauron-api-endpoint"] - if not plugin.api_endpoint: +def init(plugin, options, **kwargs): + plugin.sauron_api_endpoint = options.get("sauron-api-endpoint", None) + plugin.log("plugin.sauron_api_endpoint = %s" % plugin.sauron_api_endpoint) + + if not plugin.sauron_api_endpoint: raise SauronError("You need to specify the sauron-api-endpoint option.") sys.exit(1) @@ -58,11 +55,11 @@ def init(plugin, options, configuration, **kwargs): plugin.log("Sauron plugin initialized") plugin.log(sauron_eye) - @plugin.method("getchaininfo") def getchaininfo(plugin, **kwargs): - blockhash_url = "{}/block-height/0".format(plugin.api_endpoint) - blockcount_url = "{}/blocks/tip/height".format(plugin.api_endpoint) + blockhash_url = "{}block-height/0".format(plugin.sauron_api_endpoint) + blockcount_url = "{}blocks/tip/height".format(plugin.sauron_api_endpoint) + chains = { "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f": "main", "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943": "test", @@ -90,8 +87,6 @@ def getchaininfo(plugin, **kwargs): raise SauronError("Unsupported network") plugin.sauron_network = chains[genesis_req.text] - # We wouldn't be able to hit it if its bitcoind wasn't synced, so - # ibd = false and headercount = blockcount return { "chain": plugin.sauron_network, "blockcount": blockcount_req.text, @@ -99,10 +94,11 @@ def getchaininfo(plugin, **kwargs): "ibd": False, } - @plugin.method("getrawblockbyheight") def getrawblock(plugin, height, **kwargs): - blockhash_url = "{}/block-height/{}".format(plugin.api_endpoint, height) + # Step 1: Get the block hash by height + blockhash_url = "{}block-height/{}".format(plugin.sauron_api_endpoint, height) + blockhash_req = fetch(blockhash_url) if blockhash_req.status_code != 200: return { @@ -110,7 +106,11 @@ def getrawblock(plugin, height, **kwargs): "block": None, } - block_url = "{}/block/{}/raw".format(plugin.api_endpoint, blockhash_req.text) + block_hash = blockhash_req.text.strip() # Ensure no extra spaces or newlines + + # Step 2: Determine the block URL and fetch the block data + block_url = "{}block/{}/raw".format(plugin.sauron_api_endpoint, block_hash) + while True: block_req = fetch(block_url) if block_req.status_code != 200: @@ -130,16 +130,23 @@ def getrawblock(plugin, height, **kwargs): plugin.log("Esplora gave us an incomplete block, retrying in 2s", level="error") time.sleep(2) + plugin.log("block_req = %s" % pprint(vars(block_req))) + + # Step 3: Process the block data + # Blockstream and Mutinynet returns raw binary data + block_data = block_req.content.hex() + plugin.log("block_data = %s" % block_data) + return { - "blockhash": blockhash_req.text, - "block": block_req.content.hex(), + "blockhash": block_hash, + "block": block_data, } @plugin.method("sendrawtransaction") def sendrawtx(plugin, tx, **kwargs): - sendtx_url = "{}/tx".format(plugin.api_endpoint) - + sendtx_url = "{}tx".format(plugin.sauron_api_endpoint) + sendtx_req = requests.post(sendtx_url, data=tx) if sendtx_req.status_code != 200: return { @@ -154,57 +161,109 @@ def sendrawtx(plugin, tx, **kwargs): @plugin.method("getutxout") -def getutxout(plugin, txid, vout, **kwargs): - gettx_url = "{}/tx/{}".format(plugin.api_endpoint, txid) - status_url = "{}/tx/{}/outspend/{}".format(plugin.api_endpoint, txid, vout) +def getutxout(plugin, address, txid, vout, **kwargs): + # Determine the API endpoint type based on the URL structure + if "mutinynet" in plugin.sauron_api_endpoint: + # MutinyNet API + utxo_url = "{}address/{}/utxo".format(plugin.sauron_api_endpoint, address) + + # Fetch the list of UTXOs for the given address + utxo_req = fetch(utxo_url) + if not utxo_req.status_code == 200: + raise SauronError( + "Endpoint at {} returned {} ({}) when trying to get UTXOs.".format( + utxo_url, utxo_req.status_code, utxo_req.text + ) + ) - gettx_req = fetch(gettx_url) - if not gettx_req.status_code == 200: - raise SauronError( - "Endpoint at {} returned {} ({}) when trying to " "get transaction.".format( - gettx_url, gettx_req.status_code, gettx_req.text + # Parse the UTXO data + utxos = utxo_req.json() + # Find the UTXO with the given txid and vout + for utxo in utxos: + if utxo['txid'] == txid and utxo['vout'] == vout: + return { + "amount": utxo["value"], + "script": None # MutinyNet API does not provide script information + } + + # If the specific UTXO is not found + return { + "amount": None, + "script": None + } + + else: + # Blockstream API + gettx_url = "{}/tx/{}".format(plugin.sauron_api_endpoint, txid) + status_url = "{}/tx/{}/outspend/{}".format(plugin.sauron_api_endpoint, txid, vout) + + gettx_req = fetch(gettx_url) + if not gettx_req.status_code == 200: + raise SauronError( + "Endpoint at {} returned {} ({}) when trying to get transaction.".format( + gettx_url, gettx_req.status_code, gettx_req.text + ) ) - ) - status_req = fetch(status_url) - if not status_req.status_code == 200: - raise SauronError( - "Endpoint at {} returned {} ({}) when trying to " "get utxo status.".format( - status_url, status_req.status_code, status_req.text + status_req = fetch(status_url) + if not status_req.status_code == 200: + raise SauronError( + "Endpoint at {} returned {} ({}) when trying to get UTXO status.".format( + status_url, status_req.status_code, status_req.text + ) ) - ) - if status_req.json()["spent"]: + if status_req.json()["spent"]: + return { + "amount": None, + "script": None + } + + txo = gettx_req.json()["vout"][vout] return { - "amount": None, - "script": None, + "amount": txo["value"], + "script": txo["scriptpubkey"] } - txo = gettx_req.json()["vout"][vout] - return { - "amount": txo["value"], - "script": txo["scriptpubkey"], - } @plugin.method("estimatefees") def estimatefees(plugin, **kwargs): - feerate_url = "{}/fee-estimates".format(plugin.api_endpoint) + # Define the URL based on the selected API + if "mutinynet" in plugin.sauron_api_endpoint: + # MutinyNet API + feerate_url = "{}v1/fees/recommended".format(plugin.sauron_api_endpoint) + plugin.log("estimatefees: plugin.sauron_api_endpoint = %s" % plugin.sauron_api_endpoint) + plugin.log("estimatefees: feerate_url = %s" % feerate_url) + + else: + # Blockstream API + feerate_url = "{}/fee-estimates".format(plugin.sauron_api_endpoint) + plugin.log("estimatefees: plugin.sauron_api_endpoint = %s" % plugin.sauron_api_endpoint) + plugin.log("estimatefees: feerate_url = %s" % feerate_url) feerate_req = fetch(feerate_url) assert feerate_req.status_code == 200 feerates = feerate_req.json() - if plugin.sauron_network == "test" or plugin.sauron_network == "signet": - # FIXME: remove the hack if the test API is "fixed" + plugin.log("estimatefees: feerates = %s" % feerates) + + # Define the multiply factor for sat/vB to sat/kVB conversion + multiply_factor = 10**3 + + if plugin.sauron_network in ["test", "signet"]: + # Apply the fallback for test/signet networks feerate = feerates.get("144", 1) - slow = normal = urgent = very_urgent = int(feerate * 10**3) + slow = normal = urgent = very_urgent = int(feerate * multiply_factor) else: - # It returns sat/vB, we want sat/kVB, so multiply everything by 10**3 - slow = int(feerates["144"] * 10**3) - normal = int(feerates["12"] * 10**3) - urgent = int(feerates["6"] * 10**3) - very_urgent = int(feerates["2"] * 10**3) + # Adjust fee rates based on the specific API + slow = int(feerates["144"] * multiply_factor) + normal = int(feerates["12"] * multiply_factor) + urgent = int(feerates["6"] * multiply_factor) + very_urgent = int(feerates["2"] * multiply_factor) - feerate_floor = int(feerates["1008"] * 10**3) + # Handle missing '1008' key with a default value (e.g., `slow` as fallback) + feerate_floor = int(feerates.get("1008", slow) * multiply_factor) + + # Construct the final fee rates structure feerates = [ {"blocks": 2, "feerate": very_urgent}, {"blocks": 6, "feerate": urgent}, @@ -212,6 +271,7 @@ def estimatefees(plugin, **kwargs): {"blocks": 144, "feerate": slow} ] + # Return the estimated fees return { "opening": normal, "mutual_close": normal, @@ -225,11 +285,10 @@ def estimatefees(plugin, **kwargs): "feerates": feerates } - plugin.add_option( "sauron-api-endpoint", "", - "The URL of the esplora instance to hit (including '/api').", + "The URL of the mutinynet instance to hit (including '/api').", ) plugin.add_option(