SatSale

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

commit 4312c7682113d138483722e50eccdf4a314805a6
parent 79ccef94ea2c0ff6eb0d520122e42b170c0261e0
Author: Nick <nicholas.w.farrow@gmail.com>
Date:   Thu,  8 Jul 2021 03:59:10 +1000

satsale v2 (#12) Refactored code into REST API, client side processing. Created API docs and removed old code. Speeed

* SatSale v2 - Refactored code into REST API, client side processing. Created API docs and removed old code. Speeed

* Fix werkzeug dep

* Fix js checkpayment json bug

* fix api responses for /checkpayment
Diffstat:
MREADME.md | 2+-
Mdocs/config_lightning.py | 6+++---
Mdocs/config_remote_node.py | 8++++----
Mgateways/woo_satsale.php | 3+--
Mgateways/woo_webhook.py | 7+++----
Dinvoice/payment_invoice.py | 34----------------------------------
Minvoice/price_feed.py | 2+-
Mpay/bitcoind.py | 42++++++++++--------------------------------
Mpay/lnd.py | 60++++++++++++++++++++----------------------------------------
Mrequirements.txt | 5+++--
Msatsale.py | 406+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mssh_tunnel.py | 7++++++-
Astatic/satsale.js | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dstatic/websocket.js | 96-------------------------------------------------------------------------------
Mtemplates/index.html | 23+++++++++++++++++++----
15 files changed, 424 insertions(+), 379 deletions(-)

diff --git a/README.md b/README.md @@ -53,7 +53,7 @@ If running on a Raspberry Pi, you will want to [forward port 8000 in your router You will want to run gunicorn with nohup so it continues serving in the background: ``` -nohup gunicorn --worker-class eventlet -w 1 -b 0.0.0.0:8000 satsale:app > log.txt 2>&1 & +nohup gunicorn -w 1 -b 0.0.0.0:8000 satsale:app > log.txt 2>&1 & tail -f log.txt ``` diff --git a/docs/config_lightning.py b/docs/config_lightning.py @@ -4,7 +4,7 @@ # We will be hosting SatSale on the same machine as our node. # SatSale & Bitcoin Lightning Node 1.1.1.1 8332, 10009 (bitcoind, lnd) -# As we are running SatSale on our node, +# As we are running SatSale on our node, # it can directly talk to our node at localhost 127.0.0.1 host = "127.0.0.1" rpcport = "8332" @@ -27,7 +27,7 @@ tunnel_host = None pollrate = 15 # Payment expires after xx seconds -payment_timeout = 60*60 +payment_timeout = 60 * 60 # Required confirmations for a payment required_confirmations = 2 @@ -39,7 +39,7 @@ connection_attempts = 3 redirect = "https://github.com/nickfarrow/satsale" # Payment method has been switched to lnd -#pay_method = "bitcoind" +# pay_method = "bitcoind" pay_method = "lnd" # Specify lightning directory and port diff --git a/docs/config_remote_node.py b/docs/config_remote_node.py @@ -3,13 +3,13 @@ # and the second remote machine hosts SatSale (perhaps same server you host your website on) # Bitcoin Lightning Node 1.1.1.1 8332, 10009, 22 (bitcoind, lnd, SSH) -# SatSale 2.2.2.2 +# SatSale 2.2.2.2 # In this config we will tell SatSale to connect to our node, # tunneling the required ports over SSH. # SatSale can then talk to our node via localhost on 127.0.0.1 host = "127.0.0.1" -rpcport = "8332" # port for bitcoind +rpcport = "8332" # port for bitcoind # If connections get kicked back, you may also need to set `rpcallowip=YOUR_SERVER_IP` in your `~/.bitcoin/bitcoin.conf`. # From ~/.bitcoin/bitcoin.conf @@ -30,7 +30,7 @@ tunnel_host = "pi@1.1.1.1" pollrate = 15 # Payment expires after xx seconds -payment_timeout = 60*60 +payment_timeout = 60 * 60 # Required confirmations for a payment required_confirmations = 2 @@ -42,7 +42,7 @@ connection_attempts = 3 redirect = "https://github.com/nickfarrow/satsale" # Payment method has been switched to lnd -#pay_method = "bitcoind" +# pay_method = "bitcoind" pay_method = "lnd" # Specify lightning directory and port diff --git a/gateways/woo_satsale.php b/gateways/woo_satsale.php @@ -135,7 +135,6 @@ function satsale_init_gateway_class() { */ $args = array( 'amount' => $order->get_total(), - 'id' => $order->get_id(), 'w_url' => $this->callback_URL ); write_log($args); @@ -172,7 +171,7 @@ function satsale_init_gateway_class() { // once the payment has been confirmed by the python backend. // By confirming it matches the order details (amount * id) we know that // the order has not been tampered with after leaving the php payment gateway. - $order_secret_seed = (int)($order->get_total() * 100.0 * $order->get_id()); + $order_secret_seed = (int)($order->get_total() * 100.0); $order_secret_seed_str = (string)$order_secret_seed; $secret = hash_hmac('sha256', $order_secret_seed, $key); diff --git a/gateways/woo_webhook.py b/gateways/woo_webhook.py @@ -6,12 +6,12 @@ import time import requests -def hook(satsale_secret, payload, payment): +def hook(satsale_secret, invoice): key = codecs.decode(satsale_secret, "hex") # Calculate a secret that is required to send back to the # woocommerce gateway, proving we did not modify id nor amount. - secret_seed = str(int(100 * float(payload["amount"])) * int(payload["id"])).encode( + secret_seed = str(int(100 * float(invoice["amount"]))).encode( "utf-8" ) print("Secret seed: {}".format(secret_seed)) @@ -22,7 +22,6 @@ def hook(satsale_secret, payload, payment): paid_time = int(time.time()) params = { "wc-api": "wc_satsale_gateway", - "id": payload["id"], "time": str(paid_time), } message = (str(paid_time) + "." + json.dumps(params, separators=(",", ":"))).encode( @@ -38,6 +37,6 @@ def hook(satsale_secret, payload, payment): } # Send the webhook response, confirming the payment with woocommerce. - response = requests.get(payload["w_url"], params=params, headers=headers) + response = requests.get(invoice["w_url"], params=params, headers=headers) return response diff --git a/invoice/payment_invoice.py b/invoice/payment_invoice.py @@ -1,34 +0,0 @@ -import uuid -import qrcode - -import config -from .price_feed import get_btc_value - - -class invoice: - def __init__(self, dollar_value, currency, label, test=False): - self.dollar_value = dollar_value - self.currency = currency - self.value = round(get_btc_value(dollar_value, currency), 8) - self.uuid = str(uuid.uuid4()) - self.label = self.uuid - self.status = "Payment initialised." - self.response = "" - self.time_left = config.payment_timeout - self.confirmed_paid = 0 - self.unconfirmed_paid = 0 - self.paid = False - self.txid = "" - self.test = test - - def create_qr(self): - if config.pay_method == "lnd": - qr_str = "{}".format(self.address.upper()) - else: - qr_str = "{}?amount={}&label={}".format( - self.address.upper(), self.value, self.label - ) - - img = qrcode.make(qr_str) - img.save("static/qr_codes/{}.png".format(self.uuid)) - return diff --git a/invoice/price_feed.py b/invoice/price_feed.py @@ -36,7 +36,7 @@ def get_btc_value(dollar_value, currency): if price is not None: try: - float_value = dollar_value / float(price) + float_value = float(dollar_value) / float(price) if not isinstance(float_value, float): raise Exception("Dollar value should be a float.") except Exception as e: diff --git a/pay/bitcoind.py b/pay/bitcoind.py @@ -6,9 +6,7 @@ import config from invoice.price_feed import get_btc_value - - -class btcd(): +class btcd: def __init__(self): from bitcoinrpc.authproxy import AuthServiceProxy @@ -41,40 +39,20 @@ class btcd(): Check your RPC / port tunneling settings and try again." ) - def invoice(self, dollar_value, currency, label): - self.dollar_value = dollar_value - self.currency = currency - self.value = round(get_btc_value(dollar_value, currency), 8) - self.uuid = str(uuid.uuid4()) - self.label = self.uuid - self.status = "Payment initialised." - self.response = "" - self.time_left = config.payment_timeout - self.confirmed_paid = 0 - self.unconfirmed_paid = 0 - self.paid = False - self.txid = "" - self.get_address() - self.create_qr() - return - - def create_qr(self): - qr_str = "{}?amount={}&label={}".format( - self.address.upper(), self.value, self.label - ) + def create_qr(self, uuid, address, value): + qr_str = "{}?amount={}&label={}".format(address.upper(), value, uuid) img = qrcode.make(qr_str) - img.save("static/qr_codes/{}.png".format(self.uuid)) + img.save("static/qr_codes/{}.png".format(uuid)) return - def check_payment(self): + def check_payment(self, address): transactions = self.rpc.listtransactions() - relevant_txs = [tx for tx in transactions if tx["address"] == self.address] + relevant_txs = [tx for tx in transactions if tx["address"] == address] conf_paid = 0 unconf_paid = 0 for tx in relevant_txs: - self.txid = tx["txid"] if tx["confirmations"] >= config.required_confirmations: conf_paid += tx["amount"] else: @@ -82,11 +60,11 @@ class btcd(): return conf_paid, unconf_paid - def get_address(self): + def get_address(self, amount, label): for i in range(config.connection_attempts): try: - self.address = self.rpc.getnewaddress(self.label) - return + address = self.rpc.getnewaddress(label) + return address, None except Exception as e: print(e) print( @@ -97,4 +75,4 @@ class btcd(): if config.connection_attempts - i == 1: print("Reconnecting...") self.__init__() - return + return None diff --git a/pay/lnd.py b/pay/lnd.py @@ -12,7 +12,8 @@ import qrcode from invoice.price_feed import get_btc_value import config -class lnd(): + +class lnd: def __init__(self): from lndgrpc import LNDClient @@ -33,11 +34,10 @@ class lnd(): time.sleep(3) self.lnd = LNDClient( "{}:{}".format(config.host, config.lnd_rpcport), - macaroon_filepath=self.certs['macaroon'], - cert_filepath=self.certs['tls'], + macaroon_filepath=self.certs["macaroon"], + cert_filepath=self.certs["tls"], ) - print("Getting lnd info...") info = self.lnd.get_info() print(info) @@ -61,32 +61,15 @@ class lnd(): print("Ready for payments requests.") return - def invoice(self, dollar_value, currency, label): - self.dollar_value = dollar_value - self.currency = currency - self.value = round(get_btc_value(dollar_value, currency), 8) - self.uuid = str(uuid.uuid4()) - self.label = self.uuid - self.status = "Payment initialised." - self.response = "" - self.time_left = config.payment_timeout - self.confirmed_paid = 0 - self.unconfirmed_paid = 0 - self.paid = False - self.txid = "" - self.get_address() - self.create_qr() - return - - def create_qr(self): - qr_str = "{}".format(self.address.upper()) + def create_qr(self, uuid, address, value): + qr_str = "{}".format(address.upper()) img = qrcode.make(qr_str) - img.save("static/qr_codes/{}.png".format(self.uuid)) + img.save("static/qr_codes/{}.png".format(uuid)) return # Copy tls and macaroon certs from remote machine. def copy_certs(self): - self.certs = {'tls' : 'tls.cert', 'macaroon' : 'admin.macaroon'} + self.certs = {"tls": "tls.cert", "macaroon": "admin.macaroon"} if (not os.path.isfile("tls.cert")) or (not os.path.isfile("admin.macaroon")): try: @@ -115,8 +98,10 @@ class lnd(): ) else: - self.certs = {'tls' : os.path.expanduser(tls_file), - 'macaroon' : os.path.expanduser(macaroon_file)} + self.certs = { + "tls": os.path.expanduser(tls_file), + "macaroon": os.path.expanduser(macaroon_file), + } except Exception as e: print(e) @@ -130,26 +115,21 @@ class lnd(): # Multiplying by 10^8 to convert to satoshi units sats_amount = int(btc_amount * 10 ** 8) res = self.lnd.add_invoice(value=sats_amount) - self.lnd_invoice = json.loads(MessageToJson(res)) - self.hash = self.lnd_invoice["r_hash"] + lnd_invoice = json.loads(MessageToJson(res)) print("Created lightning invoice:") - print(self.lnd_invoice) + print(lnd_invoice) - return self.lnd_invoice["payment_request"] + return lnd_invoice["payment_request"], lnd_invoice["r_hash"] - def get_address(self): - self.address = self.create_lnd_invoice(self.value) - return + def get_address(self, amount, label): + address, r_hash = self.create_lnd_invoice(amount) + return address, r_hash # Check whether the payment has been paid - def check_payment(self): - print("Looking up invoice") - + def check_payment(self, rhash): invoice_status = json.loads( - MessageToJson( - self.lnd.lookup_invoice(r_hash_str=b64decode(self.hash).hex()) - ) + MessageToJson(self.lnd.lookup_invoice(r_hash_str=b64decode(rhash).hex())) ) if "amt_paid_sat" not in invoice_status.keys(): diff --git a/requirements.txt b/requirements.txt @@ -1,13 +1,14 @@ python_bitcoinrpc==1.0 qrcode==6.1 -Flask_SocketIO==5.0.0 Flask==1.1.2 MarkupSafe==1.1.1 requests==2.25.0 gunicorn==20.0.4 -eventlet==0.30.2 Pillow==8.01.1 protobuf==3.15.6 +flask_restplus==0.13.0 +Werkzeug==0.16.1 + # For lightning (optional) setuptools==50.3.2 diff --git a/satsale.py b/satsale.py @@ -1,7 +1,19 @@ -from flask import Flask, render_template, request, redirect -from flask_socketio import SocketIO, emit +from flask import ( + Flask, + render_template, + request, + redirect, + Blueprint, + make_response, + url_for, +) +from flask_restplus import Resource, Api, Namespace, fields import time import os +import uuid +import sqlite3 +from pprint import pprint +import json import ssh_tunnel import config @@ -9,12 +21,11 @@ import invoice from pay import bitcoind from pay import lnd from gateways import woo_webhook +from invoice.price_feed import get_btc_value -# Begin websocket -async_mode = None app = Flask(__name__) -# Load an API key or create a new one +# Load a SatSale API key or create a new one if os.path.exists("SatSale_API_key"): with open("SatSale_API_key", "r") as f: app.config["SECRET_KEY"] = f.read().strip() @@ -25,187 +36,272 @@ else: print("Initialised Flask with secret key: {}".format(app.config["SECRET_KEY"])) -# cors_allowed_origins * allows for webhooks to be initiated from iframes. -socket_ = SocketIO(app, async_mode=async_mode, cors_allowed_origins="*") - - -# Basic return on initialisation -@socket_.on("initialise") -def test_message(message): - emit( - "payresponse", - { - "status": "Initialising payment...", - "time_left": 0, - "response": "Initialising payment...", - }, - ) +# Create payment database if it does not exist +if not os.path.exists("database.db"): + with sqlite3.connect("database.db") as conn: + print("Creating new database.db...") + conn.execute( + "CREATE TABLE payments (uuid TEXT, dollar_value DECIMAL, btc_value DECIMAL, method TEXT, address TEXT, time DECIMAL, webhook TEXT, rhash TEXT)" + ) # Render index page # This is currently a donation page that submits to /pay @app.route("/") def index(): - # Render donation page - return render_template("donate.html", async_mode=socket_.async_mode) + headers = {"Content-Type": "text/html"} + return make_response(render_template("donate.html"), 200, headers) -# /pay is the main payment method for initiating a payment websocket. +# /pay is the main page for initiating a payment, takes a GET request with ?amount= @app.route("/pay") -def payment_page(): +def pay(): params = dict(request.args) - params['lnd_enabled'] = (config.pay_method == "lnd") + params["lnd_enabled"] = config.pay_method == "lnd" + params["redirect"] = config.redirect # Render payment page with the request arguments (?amount= etc.) - return render_template("index.html", params=params, async_mode=socket_.async_mode) - - -# Websocket payment processing method called by client -# initiate_payment recieves amount and initiates invoice and payment processing. -@socket_.on("initiate_payment") -def initiate_payment(payload): - # Check the amount is a float - amount = payload["amount"] - try: - amount = float(amount) - except Exception as e: - update_status(payment, "Invalid amount.") - amount = None - return - - # Label as a donation if the id is missing - if "id" in payload.keys(): - label = payload["id"] - else: - label = "donation" + headers = {"Content-Type": "text/html"} + return make_response(render_template("index.html", params=params), 200, headers) + + +# Now we build the API docs +# (if you do this before the above @app.routes then / gets overwritten.) +api = Api( + app, + version="0.1", + title="SatSale API", + default="SatSale /api/", + description="API for creating Bitcoin invoices and processing payments.", + doc="/docs/", + order=True, +) + +# Model templates for API responses +invoice_model = api.model( + "invoice", + { + "uuid": fields.String(), + "dollar_value": fields.Float(), + "btc_value": fields.Float(), + "method": fields.String(), + "address": fields.String(), + "time": fields.Float(), + "webhook": fields.String(), + "rhash": fields.String(), + "time_left": fields.Float(), + }, +) +status_model = api.model( + "status", + { + "payment_complete": fields.Integer(), + "confirmed_paid": fields.Float(), + "unconfirmed_paid": fields.Float(), + "expired": fields.Integer(), + }, +) + + +@api.doc( + params={ + "amount": "An amount in USD.", + "method": "(Optional) Specify a payment method: `bitcoind` for onchain, `lnd` for lightning).", + "w_url": "(Optional) Specify a webhook url to call after successful payment. Currently only supports WooCommerce plugin.", + } +) +class create_payment(Resource): + @api.response(200, "Success", invoice_model) + @api.response(400, "Invalid payment method") + def get(self): + "Create Payment" + """Initiate a new payment with an `amount` in USD.""" + dollar_amount = request.args.get("amount") + currency = "USD" + label = "" # request.args.get('label') + payment_method = request.args.get("method") + if payment_method is None: + payment_method = config.pay_method + webhook = request.args.get("w_url") + if webhook is None: + webhook = None + + # Create the payment using one of the connected nodes as a base + # ready to recieve the invoice. + node = get_node(payment_method) + if node is None: + print("Invalid payment method {}".format(payment_method)) + return {"message": "Invalid payment method."}, 400 + + invoice = { + "uuid": str(uuid.uuid4().hex), + "dollar_value": dollar_amount, + "btc_value": round(get_btc_value(dollar_amount, currency), 8), + "method": payment_method, + "time": time.time(), + "webhook": webhook, + } + + invoice["address"], invoice["rhash"] = node.get_address( + invoice["btc_value"], invoice["uuid"] + ) + node.create_qr(invoice["uuid"], invoice["address"], invoice["btc_value"]) + + with sqlite3.connect("database.db") as conn: + cur = conn.cursor() + cur.execute( + "INSERT INTO payments (uuid,dollar_value,btc_value,method,address,time,webhook,rhash) VALUES (?,?,?,?,?,?,?,?)", + ( + invoice["uuid"], + invoice["dollar_value"], + invoice["btc_value"], + invoice["method"], + invoice["address"], + invoice["time"], + invoice["webhook"], + invoice["rhash"], + ), + ) - # Get payment method, use one specified in query string if provided - if "method" in payload.keys(): - payment_method = payload['method'] - else: - payment_method = config.pay_method + invoice["time_left"] = config.payment_timeout - (time.time() - invoice["time"]) + print("Created invoice:") + pprint(invoice) + print() - # Initialise the payment invoice - payment = create_invoice(amount, "USD", label, payment_method) + return {"invoice": invoice}, 200 + + +@api.doc(params={"uuid": "A payment uuid. Received from /createpayment."}) +class check_payment(Resource): + @api.response(200, "Success", status_model) + @api.response(201, "Unconfirmed", status_model) + @api.response(202, "Payment Expired", status_model) + def get(self): + "Check Payment" + """Check the status of a payment.""" + uuid = request.args.get("uuid") + status = check_payment_status(uuid) + + response = { + "payment_complete": 0, + "confirmed_paid": 0, + "unconfirmed_paid": 0, + "expired": 0, + } + + if status["time_left"] <= 0: + response.update({"expired": 1}) + code = 202 + else: + # Don't send paid amounts if payment is expired. + response.update(status) - # Wait for the amount to be sent to the address - process_payment(payment) + if status['payment_complete'] == 1: + code = 200 + else: + code = 201 + + return {'status': response}, code + + +@api.doc(params={"uuid": "A payment uuid. Received from /createpayment."}) +class complete_payment(Resource): + @api.response(200, "Payment confirmed.") + @api.response(400, "Payment expired.") + @api.response(500, "Webhook failure.") + def get(self): + "Complete Payment" + """Run post-payment processing such as any webhooks.""" + uuid = request.args.get("uuid") + + invoice = load_invoice_from_db(uuid) + status = check_payment_status(uuid) - # On successful payment - if payment.paid: - update_status(payment, "Payment finalised. Thankyou!") + if status["time_left"] < 0: + return {"expired"}, 400 - # If a w_url for woocommerce webhook has been provided, then we need - # to take some additional steps to confirm the order. - if "w_url" in payload.keys(): + if (invoice["webhook"] != None) and (invoice["webhook"] != ""): # Call webhook - response = woo_webhook.hook(app.config["SECRET_KEY"], payload, payment) + response = woo_webhook.hook(app.config["SECRET_KEY"], invoice) if response.status_code != 200: - print( - "Failed to confirm order payment via webhook {}, please contact the store to ensure the order has been confirmed, error response is: {}".format( - response.status_code, response.text - ) + err = "Failed to confirm order payment via webhook {}, please contact the store to ensure the order has been confirmed, error response is: {}".format( + response.status_code, response.text ) - update_status(payment, response.text) - else: - print("Successfully confirmed payment via webhook.") - update_status(payment, "Order confirmed.") - - # Redirect after payment - # TODO: add a delay here. Test. - if config.redirect is not None: - print("Redirecting to {}".format(config.redirect)) - return redirect(config.redirect) - else: - print("No redirect, closing.") - - return - - -# Return feedback via the websocket, updating the payment status and time remaining. -def update_status(payment, status, console_status=True): - payment.status = status - payment.response = status - # Log to python stdout also - if console_status: - print(payment.status) - - # Send status & response to client - emit( - "payresponse", - { - "status": payment.status, - "address": payment.address, - "amount": payment.value, - "time_left": payment.time_left, - "uuid": payment.uuid, - "response": payment.response, - }, - ) - return - - -# Initialise the payment via the payment method (bitcoind / lightningc / etc), -def create_invoice(dollar_amount, currency, label, payment_method=config.pay_method): - if payment_method == "bitcoind": - payment = bitcoin_node - elif payment_method == "lnd": - payment = lightning_node - else: - print("Invalid payment method") - return + print(err) + return {"message": err}, 500 - # Load invoice information - payment.invoice(dollar_amount, currency, label) + print("Successfully confirmed payment via webhook.") + return {"message": "Payment confirmed with store."}, 200 - return payment + return {"message": "Payment confirmed."}, 200 -# Payment processing function. -# Handle payment logic. -def process_payment(payment): - update_status(payment, "Payment intialised, awaiting payment.") +def check_payment_status(uuid): + status = {} + invoice = load_invoice_from_db(uuid) + if invoice is None: + status.update({"time_left": 0, "not_found": 1}) + else: + status["time_left"] = config.payment_timeout - (time.time() - invoice["time"]) - # Track start_time so we can detect payment timeouts - payment.start_time = time.time() - while (config.payment_timeout - (time.time() - payment.start_time)) > 0: - # Not using := for compatability reasons.. - payment.time_left = config.payment_timeout - (time.time() - payment.start_time) - # Check progress of the payment - payment.confirmed_paid, payment.unconfirmed_paid = payment.check_payment() - print() - print(payment.__dict__) + if status["time_left"] > 0: + node = get_node(invoice["method"]) + if invoice["method"] == "lnd": + conf_paid, unconf_paid = node.check_payment(invoice["rhash"]) + else: + conf_paid, unconf_paid = node.check_payment(invoice["address"]) # Debugging and demo mode which auto confirms payments after 5 seconds - dbg_free_mode_cond = config.free_mode and (time.time() - payment.start_time > 5) + dbg_free_mode_cond = config.free_mode and (time.time() - invoice["time"] > 5) # If payment is paid - if (payment.confirmed_paid > payment.value) or dbg_free_mode_cond: - payment.paid = True - payment.time_left = 0 - update_status( - payment, "Payment successful! {} BTC".format(payment.confirmed_paid) + if (conf_paid > invoice["btc_value"]) or dbg_free_mode_cond: + status.update( + { + "payment_complete": 1, + "confirmed_paid": conf_paid, + "unconfirmed_paid": unconf_paid, + } ) - break - - # Display unconfirmed transaction info - elif payment.unconfirmed_paid > 0: - update_status( - payment, - "Discovered payment. \ - Waiting for {} confirmations...".format( - config.required_confirmations - ), - console_status=False, + else: + status.update( + { + "payment_complete": 0, + "confirmed_paid": conf_paid, + "unconfirmed_paid": unconf_paid, + } ) - socket_.sleep(config.pollrate) - # Continue waiting for transaction... - else: - update_status(payment, "Waiting for payment...") - socket_.sleep(config.pollrate) + print("Invoice {} status: {}".format(uuid, status)) + return status + + +def get_node(payment_method): + if payment_method == "bitcoind": + node = bitcoin_node + elif payment_method == "lnd": + node = lightning_node else: - update_status(payment, "Payment expired.") - return + node = None + return node + + +def load_invoice_from_db(uuid): + with sqlite3.connect("database.db") as conn: + conn.row_factory = sqlite3.Row + cur = conn.cursor() + rows = cur.execute( + "select * from payments where uuid='{}'".format(uuid) + ).fetchall() + if len(rows) > 0: + return [dict(ix) for ix in rows][0] + else: + return None + + +# Add API endpoints +api.add_resource(create_payment, "/api/createpayment") +api.add_resource(check_payment, "/api/checkpayment") +api.add_resource(complete_payment, "/api/completepayment") # Test connections on startup: @@ -218,4 +314,4 @@ if config.pay_method == "lnd": if __name__ == "__main__": - socket_.run(app, debug=False) + app.run(debug=False) diff --git a/ssh_tunnel.py b/ssh_tunnel.py @@ -1,9 +1,11 @@ import subprocess +import time import config import invoice from pay import bitcoind + def open_tunnel(host, port): # If tunnel is required (might make things easier) try: @@ -28,19 +30,22 @@ def open_tunnel(host, port): pass return + def close_tunnel(): if tunnel_proc is not None: tunnel_proc.kill() print("Tunnel closed.") return + # Open tunnel if config.tunnel_host is not None: tunnel_proc = open_tunnel(config.tunnel_host, config.rpcport) # Also for lnd if enabled - if 'lnd_rpcport' in config.__dict__.keys(): + if "lnd_rpcport" in config.__dict__.keys(): open_tunnel(config.tunnel_host, config.lnd_rpcport) + time.sleep(1) else: tunnel_proc = None diff --git a/static/satsale.js b/static/satsale.js @@ -0,0 +1,102 @@ +// Payment logic, talks to satsale.py +function payment(payment_data) { + $('document').ready(function(){ + var payment_uuid; + $.get("/api/createpayment", {amount: payment_data.amount, method: payment_data.method}).then(function(data) { + invoice = data.invoice; + payment_uuid = invoice.uuid; + + $('#address').text(invoice.address).html(); + $('#amount').text(invoice.btc_value).html(); + $('#amount_sats').text(Math.round(invoice.btc_value * 10**8)).html(); + $('#timer').text(Math.round(invoice.time_left)).html(); + + return payment_uuid + }).then(function(payment_uuid) { + load_qr(payment_uuid); + document.getElementById('timerContainer').style.visibility = "visible"; + + // Pass payment uuid and the interval process to check_payment + var checkinterval = setInterval(function() {check_payment(payment_uuid, checkinterval, payment_data);}, 1000); + }) + }); +} + +function check_payment(payment_uuid, checkinterval, payment_data) { + $.get("/api/checkpayment", {uuid: payment_uuid}).then(function(payment_data) { + payment_status = payment_data.status; + console.log(payment_status); + if (payment_status.expired == 1) { + $('#status').text("Payment expired.").html(); + document.getElementById('timerContainer').style.visibility = "hidden"; + clearInterval(checkinterval); + return 1; + } + + if (payment_status.payment_complete == 1) { + $('#status').text("Payment confirmed.").html(); + document.getElementById('timerContainer').style.visibility = "hidden"; + clearInterval(checkinterval); + complete_payment(payment_uuid, payment_data); + return 1; + } + else { + if (payment_status.unconfirmed_paid > 0) { + $('#status').text("Discovered payment. Waiting for more confirmations...").html(); + return 0; + } + else { + $('#status').text("Waiting for payment...").html(); + return 0; + } + } + }); +} + +function complete_payment(payment_uuid, payment_data) { + setTimeout(() => { window.location.replace(payment_data.redirect); }, 5000); + $.get("/api/completepayment", {uuid: payment_uuid}).then(function(payment_completion) { + console.log(payment_completion); + $('#status').text(payment_completion.message).html(); + }); +} + +function load_qr(payment_uuid) { + // Display QR code + if (payment_uuid != null) { + // Change image id to qr id + document.getElementById('qrImage').className = "qr"; + // Insert image and link + document.getElementById('qrClick').href = "/static/qr_codes/" + payment_uuid + ".png"; + document.getElementById('qrImage').src = "/static/qr_codes/" + payment_uuid + ".png"; + } +} + +function replaceUrlParam(url, paramName, paramValue) +{ + console.log(url); + var href = new URL(url); + href.searchParams.set(paramName, paramValue); + window.location = href; + return +} + +// Payment timer, can't go below zero, update every second +intervalTimer = setInterval(function () { + var currentTime = document.getElementById('timer').innerHTML; + if (currentTime <= 0) { + currentTime = 1; + } + document.getElementById('timer').innerHTML = Math.round(currentTime - 1); +}, 1000) + +// Copy text functions +function copyText(text) { + navigator.clipboard.writeText(text); +} +function copyTextFromElement(elementID) { + let element = document.getElementById(elementID); //select the element + let elementText = element.textContent; //get the text content from the element + copyText(elementText); //use the copyText function below + alert("Copied address:" + elementText) +} diff --git a/static/websocket.js b/static/websocket.js @@ -1,96 +0,0 @@ -// Websocket logic, talks to satsale.py /pay - -// Initiate is called in the <head> of index.html with the payload provided -// by the flask request. The data can not be passed straight from flask to this js -// Hence we {{ load }} it in the index head and call this function. -function open_websocket(payment_data) { - namespace = '/'; - var socket = io(namespace); - - // Echo initated message for debugging. - socket.on('connect', function() { - socket.emit('initialise', {'data': 'Initialising payment.'}); - }); - - // Recieving payment status from flask - socket.on('payresponse', function(msg) { - console.log(msg.response); - // Display payment status - $('#status').text(msg.status).html(); - // Display payment address - $('#address').text(msg.address).html(); - // Display payment amount - $('#amount').text(msg.amount).html(); - $('#amount_sats').text(Math.round(msg.amount * 10**8)).html(); - // Display payment time left - $('#timer').text(Math.round(msg.time_left)).html(); - - // Run additional logic that manipulates element visibility depending - // on the contents and status of the payment. - conditionalPageLogic(msg) - - console.log(msg); - - // Actions if paid - if (msg.paid == true) { - - // Redirect if paid - if (msg.redirect != null) { - setTimeout(() => { window.location.replace(msg.redirect); }, 5000); - } - } - }); - - // Initiate the payment websocket with the server - socket.emit('initiate_payment', payment_data); - return false -} - -// Run additional logic that manipulates element visibility depending -// on the contents and status of the payment when giving a response to the webpage. -function conditionalPageLogic(msg) { - // Display QR code - if (msg.address != null) { - // Change image id to qr id - document.getElementById('qrImage').className = "qr"; - // Insert image and link - document.getElementById('qrClick').href = "/static/qr_codes/" + msg.uuid + ".png"; - document.getElementById('qrImage').src = "/static/qr_codes/" + msg.uuid + ".png"; - } - // Hide timer until ready. - if (msg.time_left == 0) { - document.getElementById('timerContainer').style.visibility = "hidden"; - } - else { - document.getElementById('timerContainer').style.visibility = "visible"; - } -} - -function replaceUrlParam(url, paramName, paramValue) -{ - console.log(url); - var href = new URL(url); - href.searchParams.set(paramName, paramValue); - window.location = href; - return -} - -// Payment timer, can't go below zero, update every second -intervalTimer = setInterval(function () { - var currentTime = document.getElementById('timer').innerHTML; - if (currentTime <= 0) { - currentTime = 1; - } - document.getElementById('timer').innerHTML = Math.round(currentTime - 1); -}, 1000) - -// Copy text functions -function copyText(text) { - navigator.clipboard.writeText(text); -} -function copyTextFromElement(elementID) { - let element = document.getElementById(elementID); //select the element - let elementText = element.textContent; //get the text content from the element - copyText(elementText); //use the copyText function below - alert("Copied address:" + elementText) -} diff --git a/templates/index.html b/templates/index.html @@ -4,10 +4,10 @@ <title>SatSale</title> <link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}"> <meta name="viewport" content="width=device-width,initial-scale=1.0"> - + <script src="//code.jquery.com/jquery-1.12.4.min.js"></script> <script src="//cdnjs.cloudflare.com/ajax/libs/socket.io/3.0.4/socket.io.js"></script> - <script src="{{ url_for('static', filename='websocket.js') }}"></script> + <script src="{{ url_for('static', filename='satsale.js') }}"></script> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> @@ -15,7 +15,22 @@ <script type="text/javascript"> payment_data = {{ params|tojson }}; console.log(payment_data); - open_websocket(payment_data); + payment(payment_data); + + // Create invoice + // returns qr code etc + // While true + // check payment -> returns status and time left + // show if paid + // * webhook after if in params + + // SatSale.py now requires a few functions, + // * create the invoice (bitcoind or lnd) + // * lookup txn + // * process webhook + + + </script> </head> @@ -40,7 +55,7 @@ <p style="padding:0;">&nbsp&nbsp&nbsp&nbsp&nbsp(<b><span id="amount"></span></b> BTC)</p> <p style="padding:0;">To: </p><b><p id="address" onclick="copyTextFromElement('address')"></p></b> <p style="padding:0;"><span id="status"></span></p> - <p id="timerContainer" style="padding:0;"><span id="timer"></span> seconds remaining.</p> + <p id="timerContainer" style="padding:0;visibility:hidden;"><span id="timer"></span> seconds remaining.</p> </div> </br>