SatSale

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

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:
Mconfig.py | 48++++++++++++++++++++++++++++++++++++------------
Mconfig.toml | 11++++++++++-
Anode/xpub.py | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpayments/database.py | 43++++++++++++++++++++++++++++++++++++++++---
Mrequirements.txt | 1+
Msatsale.py | 10+++++++++-
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/")