commit 6b92367cb4ee6c5a28ef27b825c46c3b61d8fa2f
parent f116dad6ec1f5be87ef09825938f6a9ad7c9784b
Author: Nick <nick@nickfarrow.com>
Date: Fri, 1 Apr 2022 16:29:37 +1100
Add xpub mode with address derivation and read mempool.space (#36)
* Add pseudonode with bip32 address derivation and mempool api info
* derivation path
Co-authored-by: Kristaps Kaupe <kristaps@blogiem.lv>
* PR feedback and rename to xpub
Co-authored-by: Kristaps Kaupe <kristaps@blogiem.lv>
* check for address reuse
Co-authored-by: Kristaps Kaupe <kristaps@blogiem.lv>
* improve request rate and fast get_address
* use bip_utils instead - BIP84
* update to most latest database schemas and config
* reformat with black
* add xpub column to addresses table. Confirm address upon initial startup
* small verbosity changes
* better config.toml
Co-authored-by: Kristaps Kaupe <kristaps@blogiem.lv>
Diffstat:
6 files changed, 214 insertions(+), 17 deletions(-)
diff --git a/config.py b/config.py
@@ -4,8 +4,8 @@ import toml
for i, arg in enumerate(sys.argv):
if arg == "--conf":
- print("Using config file {}".format(sys.argv[i+1]))
- conf_path = sys.argv[i+1]
+ print("Using config file {}".format(sys.argv[i + 1]))
+ conf_path = sys.argv[i + 1]
break
else:
conf_path = "config.toml"
@@ -13,12 +13,14 @@ else:
with open(conf_path, "r") as config_file:
config = toml.load(config_file)
+
def get_opt(name, default):
- if name in config['satsale']:
- return config['satsale'][name]
+ if name in config["satsale"]:
+ return config["satsale"][name]
else:
return default
+
def check_set_node_conf(name, default, node_conf):
if name not in node_conf:
if default is not None and default != "":
@@ -28,19 +30,29 @@ def check_set_node_conf(name, default, node_conf):
payment_methods = []
-for method_name in config['payment_methods']:
+# This could be cleaned up into a single function that takes args, defaults, and required args.
+for method_name in config["payment_methods"]:
method_config = config[method_name]
if method_name == "bitcoind":
- method_config['name'] = "bitcoind"
+ method_config["name"] = "bitcoind"
check_set_node_conf("rpcport", "8332", method_config)
check_set_node_conf("username", "bitcoinrpc", method_config)
- check_set_node_conf("password", "rpcpassword", method_config)
+ check_set_node_conf("password", "", method_config)
check_set_node_conf("rpc_cookie_file", "", method_config)
check_set_node_conf("wallet", "", method_config)
check_set_node_conf("tor_bitcoinrpc_host", None, method_config)
+ if (method_config["password"] == "" or method_config["password"] is None) and (
+ method_config["rpc_cookie_file"] == ""
+ or method_config["rpc_cookie_file"] is None
+ ):
+ raise KeyError(
+ "Mising {} config: {} or {}".format(
+ method_name, "password", "rpc_cookie_file"
+ )
+ )
elif method_name == "lnd":
- method_config['name'] = "lnd"
+ method_config["name"] = "lnd"
check_set_node_conf("lnd_dir", "~/.lnd/", method_config)
check_set_node_conf("lnd_rpcport", "10009", method_config)
check_set_node_conf("lnd_macaroon", "invoice.macaroon", method_config)
@@ -48,10 +60,23 @@ for method_name in config['payment_methods']:
check_set_node_conf("lightning_address_comment", None, method_config)
elif method_name == "clightning":
- method_config['name'] = "clightning"
+ method_config["name"] = "clightning"
check_set_node_conf("clightning_rpc_file", None, method_config)
check_set_node_conf("lightning_address", None, method_config)
check_set_node_conf("lightning_address_comment", None, method_config)
+ if (
+ method_config["clightning_rpc_file"] == ""
+ or method_config["clightning_rpc_file"] is None
+ ):
+ raise KeyError(
+ "Mising {}: config {}".format(method_name, "clightning_rpc_file")
+ )
+
+ elif method_name == "xpub":
+ method_config["name"] = "xpub"
+ check_set_node_conf("xpub", None, method_config)
+ if method_config["xpub"] == "" or method_config["xpub"] is None:
+ raise KeyError("Mising {}: config {}".format(method_name, "xpub"))
else:
Exception("Unknown payment method: {}".format(method_name))
@@ -66,7 +91,7 @@ tor_proxy = get_opt("tor_proxy", None)
onchain_dust_limit = get_opt("onchain_dust_limit", 0.00000546)
node_info = get_opt("node_info", None)
pollrate = get_opt("pollrate", 15)
-payment_timeout = get_opt("payment_timeout", 60*60)
+payment_timeout = get_opt("payment_timeout", 60 * 60)
required_confirmations = get_opt("required_confirmations", 2)
connection_attempts = get_opt("connection_attempts", 3)
redirect = get_opt("redirect", "https://github.com/nickfarrow/satsale")
@@ -78,4 +103,4 @@ free_mode = get_opt("free_mode", False)
loglevel = get_opt("loglevel", "DEBUG")
print(config)
-print(tunnel_host)
-\ No newline at end of file
+print(tunnel_host)
diff --git a/config.toml b/config.toml
@@ -1,5 +1,7 @@
# SatSale Config :: Configure payment nodes and SatSale settings
payment_methods = ["bitcoind"]
+# SatSale can connect to your own bitcoin/lnd/clightning node, with the correct connection details set in this config.
+# Otherwise, you can just use `payment_method=['xpub']` with an `xpub=...` (found in your wallet) to derive onchain addresses.
## BITCOIND :: Add "bitcoind" to payment_methods and from ~/.bitcoin/bitcoin.conf
# use either username / password pair or rpc_cookie_file
@@ -7,11 +9,18 @@ payment_methods = ["bitcoind"]
[bitcoind]
host = "127.0.0.1"
username = "bitcoinrpc"
-password = "rpcpassword"
+password = ""
rpcport = "8332"
#rpc_cookie_file =
wallet = ""
+## XPUB :: Add "xpub" to payment_methods and enter your xpub below.
+# You should strongly consider running a node and using it to verify payments rather than trusting block explorers.
+# You MUST ensure you manually confirm the first address matches what you expect in your wallet! See output.
+[xpub]
+xpub = ""
+bip = "BIP84" # For bc1q addresses (xpub will start like `zpub..`). Can use BIP44 for 1egacy.
+
## LND :: Add "lnd" to payment_methods
# You can display your node connection so users can open channels with you by setting
# node_info="uri" (manually) or true (fetch if macaroon has access to `getinfo`)
diff --git a/node/xpub.py b/node/xpub.py
@@ -0,0 +1,118 @@
+import time
+import uuid
+import qrcode
+import json
+import os
+import logging
+import requests
+from bip_utils import Bip84, Bip44Changes, Bip84Coins, Bip44, Bip44Coins
+
+import config
+from payments.price_feed import get_btc_value
+from utils import btc_amount_format
+from payments import database
+
+
+class xpub:
+ def __init__(self, node_config):
+ self.is_onchain = True
+ self.config = node_config
+ self.api = "https://mempool.space/api"
+
+ next_n = self.get_next_address_index(self.config["xpub"])
+ if next_n == 0:
+ logging.info(
+ "Deriving addresses for first time from xpub: {}".format(
+ self.config["xpub"]
+ )
+ )
+ logging.warn(
+ "YOU MUST CHECK THIS MATCHES THE FIRST ADDRESS IN YOUR WALLET:"
+ )
+ logging.warn(self.get_address_at_index(next_n))
+ time.sleep(10)
+
+ logging.info("Fetching blockchain info from {}".format(self.api))
+ logging.info("Next address shown to users is #{}".format(next_n))
+
+ def create_qr(self, uuid, address, value):
+ qr_str = "bitcoin:{}?amount={}&label={}".format(
+ address, btc_amount_format(value), uuid
+ )
+
+ img = qrcode.make(qr_str)
+ img.save("static/qr_codes/{}.png".format(uuid))
+ return
+
+ def check_payment(self, address, slow=True):
+ conf_paid, unconf_paid = 0, 0
+ try:
+ r = requests.get(self.api + "/address/{}".format(address))
+ r.raise_for_status()
+ stats = r.json()
+ conf_paid = stats["chain_stats"]["funded_txo_sum"] / (10 ** 8)
+ unconf_paid = stats["mempool_stats"]["funded_txo_sum"] / (10 ** 8)
+
+ # Don't request too often
+ if slow and (conf_paid == 0):
+ time.sleep(1)
+
+ return conf_paid, unconf_paid
+
+ except Exception as e:
+ logging.error(
+ "Failed to fetch address information from mempool: {}".format(e)
+ )
+
+ return 0, 0
+
+ def get_next_address_index(self, xpub):
+ n = database.get_next_address_index(xpub)
+ return n
+
+ def get_address_at_index(self, index):
+ if self.config["bip"] == "BIP84":
+ bip84_acc = Bip84.FromExtendedKey(self.config["xpub"], Bip84Coins.BITCOIN)
+ child_key = bip84_acc.Change(Bip44Changes.CHAIN_EXT).AddressIndex(index)
+ elif self.config["bip"] == "BIP44":
+ bip44_acc = Bip44.FromExtendedKey(self.config["xpub"], Bip44Coins.BITCOIN)
+ child_key = bip44_acc.Change(Bip44Changes.CHAIN_EXT).AddressIndex(index)
+ else:
+ raise NotImplementedError(
+ "{} is not yet implemented!".format(self.config["bip"])
+ )
+
+ address = child_key.PublicKey().ToAddress()
+ return address
+
+ def get_address(self, amount, label):
+ while True:
+ n = self.get_next_address_index(self.config["xpub"])
+ address = self.get_address_at_index(n)
+ database.add_generated_address(n, address, self.config["xpub"])
+ conf_paid, unconf_paid = self.check_payment(address, slow=False)
+ if conf_paid == 0 and unconf_paid == 0:
+ break
+ return address, None
+
+
+def test():
+ # Account 0, root = m/84'/0'/0'
+ test_zpub = "zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs"
+ pseudonode = xpub({"xpub": test_zpub, "bip": "BIP84"})
+ assert (
+ pseudonode.get_address_at_index(0)
+ == "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu"
+ )
+ assert (
+ pseudonode.get_address_at_index(1)
+ == "bc1qnjg0jd8228aq7egyzacy8cys3knf9xvrerkf9g"
+ )
+ print("BIP84 test succeded")
+
+ test_xpub = "xpub6C5uh2bEhmF8ck3LSnNsj261dt24wrJHMcsXcV25MjrYNo3ZiduE3pS2Xs7nKKTR6kGPDa8jemxCQPw6zX2LMEA6VG2sypt2LUJRHb8G63i"
+ pseudonode2 = xpub({"xpub": test_xpub, "bip": "BIP44"})
+ assert pseudonode2.get_address_at_index(0) == "1LLNwhAMsS3J9tZR2T4fFg2ibuZyRSxFZg"
+ assert pseudonode2.get_address_at_index(1) == "1EaEuwMRVKdWBoKeJZzJ8abUzVbWNhGhtC"
+ print("BIP44 test succeded")
+ return
diff --git a/payments/database.py b/payments/database.py
@@ -22,8 +22,9 @@ def _set_database_schema_version(version, name="database.db"):
def _log_migrate_database(from_version, to_version, message):
- logging.info("Migrating database from {} to {}: {}".format(
- from_version, to_version, message))
+ logging.info(
+ "Migrating database from {} to {}: {}".format(from_version, to_version, message)
+ )
def migrate_database(name="database.db"):
@@ -42,6 +43,12 @@ def migrate_database(name="database.db"):
conn.execute("CREATE TABLE schema_version (version INT)")
conn.execute("INSERT INTO schema_version (version) VALUES (1)")
+ if schema_version < 2:
+ _log_migrate_database(1, 2, "Creating new table for generated addresses")
+ with sqlite3.connect(name) as conn:
+ conn.execute("CREATE TABLE addresses (n INTEGER, address TEXT, xpub TEXT)")
+ _set_database_schema_version(2)
+
#if schema_version < 2:
# do next migration
@@ -49,7 +56,9 @@ def migrate_database(name="database.db"):
if schema_version != new_version:
logging.info(
"Finished migrating database schema from version {} to {}".format(
- schema_version, new_version))
+ schema_version, new_version
+ )
+ )
def write_to_database(invoice, name="database.db"):
@@ -85,3 +94,31 @@ def load_invoice_from_db(uuid, name="database.db"):
return [dict(ix) for ix in rows][0]
else:
return None
+
+
+def add_generated_address(index, address, xpub):
+ with sqlite3.connect("database.db") as conn:
+ cur = conn.cursor()
+ cur.execute(
+ "INSERT INTO addresses (n, address, xpub) VALUES (?,?,?)",
+ (
+ index,
+ address,
+ xpub,
+ ),
+ )
+ return
+
+
+def get_next_address_index(xpub):
+ with sqlite3.connect("database.db") as conn:
+ conn.row_factory = sqlite3.Row
+ cur = conn.cursor()
+ addresses = cur.execute(
+ "SELECT n FROM addresses WHERE xpub='{}' ORDER BY n DESC LIMIT 1".format(xpub)
+ ).fetchall()
+
+ if len(addresses) == 0:
+ return 0
+ else:
+ return max([dict(addr)["n"] for addr in addresses]) + 1
diff --git a/requirements.txt b/requirements.txt
@@ -10,6 +10,7 @@ flask_restplus==0.13.0
Werkzeug==0.16.1
PySocks==1.7.1
toml==0.10.2
+bip-utils==2.3.0
# For lightning (optional)
setuptools==50.3.2
diff --git a/satsale.py b/satsale.py
@@ -31,6 +31,7 @@ from gateways import paynym
from payments import database, weakhands
from payments.price_feed import get_btc_value
from node import bitcoind
+from node import xpub
from node import lnd
from node import clightning
from utils import btc_amount_format
@@ -180,7 +181,7 @@ class create_payment(Resource):
invoice["btc_value"], invoice["uuid"]
)
except Exception as e:
- logging.error("Failed to fetch address")
+ logging.error("Failed to fetch address: {}".format(e))
return {"message": "Error fetching address. Check config.."}, 522
node.create_qr(invoice["uuid"], invoice["address"], invoice["btc_value"])
@@ -296,6 +297,8 @@ def check_payment_status(uuid):
elif (node.config['name'] == "bitcoind") or (node.config['name'] == "clightning"):
# Lookup bitcoind / clightning invoice based on label (uuid)
conf_paid, unconf_paid = node.check_payment(invoice["uuid"])
+ elif node.config['name'] == "xpub":
+ conf_paid, unconf_paid = node.check_payment(invoice["address"])
# Remove any Decimal types
conf_paid, unconf_paid = float(conf_paid), float(unconf_paid)
@@ -362,6 +365,11 @@ for method in config.payment_methods:
logging.info("Connection to lightning node (clightning) successful.")
enabled_payment_methods.append("lightning")
+ elif method['name'] == "xpub":
+ bitcoin_node = xpub.xpub(method)
+ logging.info("Not connecting to a bitcoin node, using xpubs and blockexplorer APIs.")
+ enabled_payment_methods.append("onchain")
+
# Add node connection page
if config.node_info is not None:
@app.route("/node/")