commit 747ab7a0a255f183e61feded3f4a0ec804fbde35
parent bd578807997ee6dc13e6a5e996d7c3eb68c01af0
Author: ThomasV <thomasv@electrum.org>
Date: Tue, 3 Sep 2019 14:44:33 +0200
Integrate http_server (previously in electrum-merchant)
Use submodule to fetch HTML and CSS files
Diffstat:
10 files changed, 123 insertions(+), 237 deletions(-)
diff --git a/.gitmodules b/.gitmodules
@@ -4,3 +4,6 @@
[submodule "contrib/CalinsQRReader"]
path = contrib/osx/CalinsQRReader
url = https://github.com/spesmilo/CalinsQRReader
+[submodule "electrum/www"]
+ path = electrum/www
+ url = git@github.com:spesmilo/electrum-http.git
diff --git a/electrum/commands.py b/electrum/commands.py
@@ -1039,7 +1039,6 @@ arg_types = {
config_variables = {
'addrequest': {
- 'requests_dir': 'directory where a bip70 file will be written.',
'ssl_privkey': 'Path to your SSL private key, needed to sign the request.',
'ssl_chain': 'Chain of SSL certificates, needed for signed requests. Put your certificate at the top and the root CA at the end',
'url_rewrite': 'Parameters passed to str.replace(), in order to create the r= part of bitcoin: URIs. Example: \"(\'file:///var/www/\',\'https://electrum.org/\')\"',
diff --git a/electrum/daemon.py b/electrum/daemon.py
@@ -33,6 +33,7 @@ from typing import Dict, Optional, Tuple
import aiohttp
from aiohttp import web
from base64 import b64decode
+from collections import defaultdict
import jsonrpcclient
import jsonrpcserver
@@ -41,6 +42,7 @@ from jsonrpcclient.clients.aiohttp_client import AiohttpClient
from .network import Network
from .util import (json_decode, to_bytes, to_string, profiler, standardize_path, constant_time_compare)
+from .util import PR_PAID, PR_EXPIRED, get_request_status
from .wallet import Wallet, Abstract_Wallet
from .storage import WalletStorage
from .commands import known_commands, Commands
@@ -168,6 +170,79 @@ class WatchTowerServer(Logger):
async def add_sweep_tx(self, *args):
return await self.lnwatcher.sweepstore.add_sweep_tx(*args)
+class HttpServer(Logger):
+
+ def __init__(self, daemon):
+ Logger.__init__(self)
+ self.daemon = daemon
+ self.config = daemon.config
+ self.pending = defaultdict(asyncio.Event)
+ self.daemon.network.register_callback(self.on_payment, ['payment_received'])
+
+ async def on_payment(self, evt, *args):
+ print(evt, args)
+ #await self.pending[key].set()
+
+ async def run(self):
+ from aiohttp import helpers
+ app = web.Application()
+ #app.on_response_prepare.append(http_server.on_response_prepare)
+ app.add_routes([web.post('/api/create_invoice', self.create_request)])
+ app.add_routes([web.get('/api/get_invoice', self.get_request)])
+ app.add_routes([web.get('/api/get_status', self.get_status)])
+ app.add_routes([web.static('/electrum', 'electrum/www')])
+ runner = web.AppRunner(app)
+ await runner.setup()
+ host = self.config.get('http_host', 'localhost')
+ port = self.config.get('http_port', 8000)
+ site = web.TCPSite(runner, port=port, host=host)
+ await site.start()
+
+ async def create_request(self, request):
+ params = await request.post()
+ wallet = self.daemon.wallet
+ if 'amount_sat' not in params or not params['amount_sat'].isdigit():
+ raise web.HTTPUnsupportedMediaType()
+ amount = int(params['amount_sat'])
+ message = params['message'] or "donation"
+ payment_hash = await wallet.lnworker._add_invoice_coro(amount, message, 3600)
+ key = payment_hash.hex()
+ raise web.HTTPFound('/electrum/index.html?id=' + key)
+
+ async def get_request(self, r):
+ key = r.query_string
+ request = self.daemon.wallet.get_request(key)
+ return web.json_response(request)
+
+ async def get_status(self, request):
+ ws = web.WebSocketResponse()
+ await ws.prepare(request)
+ key = request.query_string
+ info = self.daemon.wallet.get_request(key)
+ if not info:
+ await ws.send_str('unknown invoice')
+ await ws.close()
+ return ws
+ if info.get('status') == PR_PAID:
+ await ws.send_str(f'already paid')
+ await ws.close()
+ return ws
+ if info.get('status') == PR_EXPIRED:
+ await ws.send_str(f'invoice expired')
+ await ws.close()
+ return ws
+ while True:
+ try:
+ await asyncio.wait_for(self.pending[key].wait(), 1)
+ break
+ except asyncio.TimeoutError:
+ # send data on the websocket, to keep it alive
+ await ws.send_str('waiting')
+ await ws.send_str('paid')
+ await ws.close()
+ return ws
+
+
class AuthenticationError(Exception):
pass
@@ -197,6 +272,9 @@ class Daemon(Logger):
if listen_jsonrpc:
jobs.append(self.start_jsonrpc(config, fd))
# server-side watchtower
+ self.http_server = HttpServer(self)
+ if self.http_server:
+ jobs.append(self.http_server.run())
self.watchtower = WatchTowerServer(self.network) if self.config.get('watchtower_host') else None
if self.watchtower:
jobs.append(self.watchtower.run)
@@ -296,6 +374,7 @@ class Daemon(Logger):
wallet = Wallet(storage)
wallet.start_network(self.network)
self.wallets[path] = wallet
+ self.wallet = wallet
return wallet
def add_wallet(self, wallet: Abstract_Wallet):
diff --git a/electrum/gui/qt/invoice_list.py b/electrum/gui/qt/invoice_list.py
@@ -151,4 +151,4 @@ class InvoiceList(MyTreeView):
def create_menu_ln_payreq(self, menu, payreq_key):
req = self.parent.wallet.lnworker.invoices[payreq_key][0]
menu.addAction(_("Copy Lightning invoice"), lambda: self.parent.do_copy('Lightning invoice', req))
- menu.addAction(_("Delete"), lambda: self.parent.delete_lightning_payreq(payreq_key))
+ menu.addAction(_("Delete"), lambda: self.parent.delete_invoice(payreq_key))
diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py
@@ -1028,9 +1028,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger):
return w
-
- def delete_payment_request(self, addr):
- self.wallet.remove_payment_request(addr, self.config)
+ def delete_request(self, key):
+ self.wallet.delete_request(key)
self.request_list.update()
self.clear_receive_tab()
diff --git a/electrum/gui/qt/request_list.py b/electrum/gui/qt/request_list.py
@@ -40,13 +40,11 @@ from electrum.bitcoin import COIN
from electrum.lnaddr import lndecode
import electrum.constants as constants
-from .util import MyTreeView, pr_icons, read_QIcon
+from .util import MyTreeView, pr_icons, read_QIcon, webopen
-REQUEST_TYPE_BITCOIN = 0
-REQUEST_TYPE_LN = 1
ROLE_REQUEST_TYPE = Qt.UserRole
-ROLE_RHASH_OR_ADDR = Qt.UserRole + 1
+ROLE_KEY = Qt.UserRole + 1
class RequestList(MyTreeView):
@@ -76,7 +74,7 @@ class RequestList(MyTreeView):
def select_key(self, key):
for i in range(self.model().rowCount()):
item = self.model().index(i, self.Columns.DATE)
- row_key = item.data(ROLE_RHASH_OR_ADDR)
+ row_key = item.data(ROLE_KEY)
if key == row_key:
self.selectionModel().setCurrentIndex(item, QItemSelectionModel.SelectCurrent | QItemSelectionModel.Rows)
break
@@ -85,12 +83,12 @@ class RequestList(MyTreeView):
# TODO use siblingAtColumn when min Qt version is >=5.11
item = self.model().itemFromIndex(idx.sibling(idx.row(), self.Columns.DATE))
request_type = item.data(ROLE_REQUEST_TYPE)
- key = item.data(ROLE_RHASH_OR_ADDR)
- is_lightning = request_type == REQUEST_TYPE_LN
- req = self.wallet.get_request(key, is_lightning)
+ key = item.data(ROLE_KEY)
+ req = self.wallet.get_request(key)
if req is None:
self.update()
return
+ is_lightning = request_type == PR_TYPE_LN
text = req.get('invoice') if is_lightning else req.get('URI')
self.parent.receive_address_e.setText(text)
@@ -101,9 +99,9 @@ class RequestList(MyTreeView):
date_idx = idx.sibling(idx.row(), self.Columns.DATE)
date_item = m.itemFromIndex(date_idx)
status_item = m.itemFromIndex(idx)
- key = date_item.data(ROLE_RHASH_OR_ADDR)
- is_lightning = date_item.data(ROLE_REQUEST_TYPE) == REQUEST_TYPE_LN
- req = self.wallet.get_request(key, is_lightning)
+ key = date_item.data(ROLE_KEY)
+ is_lightning = date_item.data(ROLE_REQUEST_TYPE) == PR_TYPE_LN
+ req = self.wallet.get_request(key)
if req:
status = req['status']
status_str = get_request_status(req)
@@ -121,7 +119,7 @@ class RequestList(MyTreeView):
if status == PR_PAID:
continue
is_lightning = req['type'] == PR_TYPE_LN
- request_type = REQUEST_TYPE_LN if is_lightning else REQUEST_TYPE_BITCOIN
+ request_type = req['type']
timestamp = req.get('time', 0)
amount = req.get('amount')
message = req['message'] if is_lightning else req['memo']
@@ -133,18 +131,17 @@ class RequestList(MyTreeView):
self.set_editability(items)
items[self.Columns.DATE].setData(request_type, ROLE_REQUEST_TYPE)
items[self.Columns.STATUS].setIcon(read_QIcon(pr_icons.get(status)))
- if request_type == REQUEST_TYPE_LN:
- items[self.Columns.DATE].setData(req['rhash'], ROLE_RHASH_OR_ADDR)
+ if request_type == PR_TYPE_LN:
+ items[self.Columns.DATE].setData(req['rhash'], ROLE_KEY)
items[self.Columns.DATE].setIcon(read_QIcon("lightning.png"))
- items[self.Columns.DATE].setData(REQUEST_TYPE_LN, ROLE_REQUEST_TYPE)
- else:
+ elif request_type == PR_TYPE_ADDRESS:
address = req['address']
if address not in domain:
continue
expiration = req.get('exp', None)
signature = req.get('sig')
requestor = req.get('name', '')
- items[self.Columns.DATE].setData(address, ROLE_RHASH_OR_ADDR)
+ items[self.Columns.DATE].setData(address, ROLE_KEY)
if signature is not None:
items[self.Columns.DATE].setIcon(read_QIcon("seal.png"))
items[self.Columns.DATE].setToolTip(f'signed by {requestor}')
@@ -167,13 +164,9 @@ class RequestList(MyTreeView):
item = self.model().itemFromIndex(idx.sibling(idx.row(), self.Columns.DATE))
if not item:
return
- addr = item.data(ROLE_RHASH_OR_ADDR)
+ key = item.data(ROLE_KEY)
request_type = item.data(ROLE_REQUEST_TYPE)
- assert request_type in [REQUEST_TYPE_BITCOIN, REQUEST_TYPE_LN]
- if request_type == REQUEST_TYPE_BITCOIN:
- req = self.wallet.receive_requests.get(addr)
- elif request_type == REQUEST_TYPE_LN:
- req = self.wallet.lnworker.invoices[addr][0]
+ req = self.wallet.get_request(key)
if req is None:
self.update()
return
@@ -184,19 +177,15 @@ class RequestList(MyTreeView):
if column == self.Columns.AMOUNT:
column_data = column_data.strip()
menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.do_copy(column_title, column_data))
- if request_type == REQUEST_TYPE_BITCOIN:
- self.create_menu_bitcoin_payreq(menu, addr)
- elif request_type == REQUEST_TYPE_LN:
- self.create_menu_ln_payreq(menu, addr, req)
- menu.exec_(self.viewport().mapToGlobal(position))
- def create_menu_bitcoin_payreq(self, menu, addr):
- menu.addAction(_("Copy Address"), lambda: self.parent.do_copy('Address', addr))
- menu.addAction(_("Copy URI"), lambda: self.parent.do_copy('URI', self.wallet.get_request_URI(addr)))
- menu.addAction(_("Save as BIP70 file"), lambda: self.parent.export_payment_request(addr))
- menu.addAction(_("Delete"), lambda: self.parent.delete_payment_request(addr))
- run_hook('receive_list_menu', menu, addr)
+ #menu.addAction(_("Copy Address"), lambda: self.parent.do_copy('Address', addr))
+ menu.addAction(_("Copy Request"), lambda: self.parent.do_copy('URI', self.wallet.get_request_URI(addr)))
+ if 'http_url' in req:
+ menu.addAction(_("View in web browser"), lambda: webopen(req['http_url']))
- def create_menu_ln_payreq(self, menu, payreq_key, req):
- menu.addAction(_("Copy Lightning invoice"), lambda: self.parent.do_copy('Lightning invoice', req))
- menu.addAction(_("Delete"), lambda: self.parent.delete_lightning_payreq(payreq_key))
+ # do bip70 only for browser access
+ # so, each request should have an ID, regardless
+ #menu.addAction(_("Save as BIP70 file"), lambda: self.parent.export_payment_request(addr))
+ menu.addAction(_("Delete"), lambda: self.parent.delete_request(key))
+ run_hook('receive_list_menu', menu, key)
+ menu.exec_(self.viewport().mapToGlobal(position))
diff --git a/electrum/wallet.py b/electrum/wallet.py
@@ -1279,32 +1279,6 @@ class Abstract_Wallet(AddressSynchronizer):
out['status'] = status
if conf is not None:
out['confirmations'] = conf
- # check if bip70 file exists
- rdir = config.get('requests_dir')
- if rdir:
- key = out.get('id', addr)
- path = os.path.join(rdir, 'req', key[0], key[1], key)
- if os.path.exists(path):
- baseurl = 'file://' + rdir
- rewrite = config.get('url_rewrite')
- if rewrite:
- try:
- baseurl = baseurl.replace(*rewrite)
- except BaseException as e:
- self.logger.info(f'Invalid config setting for "url_rewrite". err: {e}')
- out['request_url'] = os.path.join(baseurl, 'req', key[0], key[1], key, key)
- out['URI'] += '&r=' + out['request_url']
- out['index_url'] = os.path.join(baseurl, 'index.html') + '?id=' + key
- websocket_server_announce = config.get('websocket_server_announce')
- if websocket_server_announce:
- out['websocket_server'] = websocket_server_announce
- else:
- out['websocket_server'] = config.get('websocket_server', 'localhost')
- websocket_port_announce = config.get('websocket_port_announce')
- if websocket_port_announce:
- out['websocket_port'] = websocket_port_announce
- else:
- out['websocket_port'] = config.get('websocket_port', 9999)
return out
def get_request_URI(self, addr):
@@ -1346,11 +1320,19 @@ class Abstract_Wallet(AddressSynchronizer):
status = PR_INFLIGHT if conf <= 0 else PR_PAID
return status, conf
- def get_request(self, key, is_lightning):
- if not is_lightning:
+ def get_request(self, key):
+ from .simple_config import get_config
+ config = get_config()
+ if key in self.receive_requests:
req = self.get_payment_request(key, {})
else:
req = self.lnworker.get_request(key)
+ if not req:
+ return
+ if config.get('http_port', 8000):
+ host = config.get('http_host', 'localhost')
+ port = config.get('http_port', 8000)
+ req['http_url'] = 'http://%s:%d/electrum/index.html?id=%s'%(host, port, key)
return req
def receive_tx_callback(self, tx_hash, tx, tx_height):
@@ -1389,24 +1371,6 @@ class Abstract_Wallet(AddressSynchronizer):
self.receive_requests[addr] = req
self.storage.put('payment_requests', self.receive_requests)
self.set_label(addr, message) # should be a default label
-
- rdir = config.get('requests_dir')
- if rdir and amount is not None:
- key = req.get('id', addr)
- pr = paymentrequest.make_request(config, req)
- path = os.path.join(rdir, 'req', key[0], key[1], key)
- if not os.path.exists(path):
- try:
- os.makedirs(path)
- except OSError as exc:
- if exc.errno != errno.EEXIST:
- raise
- with open(os.path.join(path, key), 'wb') as f:
- f.write(pr.SerializeToString())
- # reload
- req = self.get_payment_request(addr, config)
- with open(os.path.join(path, key + '.json'), 'w', encoding='utf-8') as f:
- f.write(json.dumps(req))
return req
def delete_request(self, key):
@@ -1427,14 +1391,7 @@ class Abstract_Wallet(AddressSynchronizer):
def remove_payment_request(self, addr, config):
if addr not in self.receive_requests:
return False
- r = self.receive_requests.pop(addr)
- rdir = config.get('requests_dir')
- if rdir:
- key = r.get('id', addr)
- for s in ['.json', '']:
- n = os.path.join(rdir, 'req', key[0], key[1], key, key + s)
- if os.path.exists(n):
- os.unlink(n)
+ self.receive_requests.pop(addr)
self.storage.put('payment_requests', self.receive_requests)
return True
diff --git a/electrum/websockets.py b/electrum/websockets.py
@@ -1,132 +0,0 @@
-#!/usr/bin/env python
-#
-# Electrum - lightweight Bitcoin client
-# Copyright (C) 2015 Thomas Voegtlin
-#
-# Permission is hereby granted, free of charge, to any person
-# obtaining a copy of this software and associated documentation files
-# (the "Software"), to deal in the Software without restriction,
-# including without limitation the rights to use, copy, modify, merge,
-# publish, distribute, sublicense, and/or sell copies of the Software,
-# and to permit persons to whom the Software is furnished to do so,
-# subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be
-# included in all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
-# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
-# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
-# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-# SOFTWARE.
-import threading
-import os
-import json
-from collections import defaultdict
-import asyncio
-from typing import Dict, List, Tuple, TYPE_CHECKING
-import traceback
-import sys
-
-try:
- from SimpleWebSocketServer import WebSocket, SimpleSSLWebSocketServer
-except ImportError:
- sys.exit("install SimpleWebSocketServer")
-
-from . import bitcoin
-from .synchronizer import SynchronizerBase
-from .logging import Logger
-
-if TYPE_CHECKING:
- from .network import Network
- from .simple_config import SimpleConfig
-
-
-request_queue = asyncio.Queue()
-
-
-class ElectrumWebSocket(WebSocket, Logger):
-
- def __init__(self):
- WebSocket.__init__(self)
- Logger.__init__(self)
-
- def handleMessage(self):
- assert self.data[0:3] == 'id:'
- self.logger.info(f"message received {self.data}")
- request_id = self.data[3:]
- asyncio.run_coroutine_threadsafe(
- request_queue.put((self, request_id)), asyncio.get_event_loop())
-
- def handleConnected(self):
- self.logger.info(f"connected {self.address}")
-
- def handleClose(self):
- self.logger.info(f"closed {self.address}")
-
-
-class BalanceMonitor(SynchronizerBase):
-
- def __init__(self, config: 'SimpleConfig', network: 'Network'):
- SynchronizerBase.__init__(self, network)
- self.config = config
- self.expected_payments = defaultdict(list) # type: Dict[str, List[Tuple[WebSocket, int]]]
-
- def make_request(self, request_id):
- # read json file
- rdir = self.config.get('requests_dir')
- n = os.path.join(rdir, 'req', request_id[0], request_id[1], request_id, request_id + '.json')
- with open(n, encoding='utf-8') as f:
- s = f.read()
- d = json.loads(s)
- addr = d.get('address')
- amount = d.get('amount')
- return addr, amount
-
- async def main(self):
- # resend existing subscriptions if we were restarted
- for addr in self.expected_payments:
- await self._add_address(addr)
- # main loop
- while True:
- ws, request_id = await request_queue.get()
- try:
- addr, amount = self.make_request(request_id)
- except Exception:
- self.logger.exception('')
- continue
- self.expected_payments[addr].append((ws, amount))
- await self._add_address(addr)
-
- async def _on_address_status(self, addr, status):
- self.logger.info(f'new status for addr {addr}')
- sh = bitcoin.address_to_scripthash(addr)
- balance = await self.network.get_balance_for_scripthash(sh)
- for ws, amount in self.expected_payments[addr]:
- if not ws.closed:
- if sum(balance.values()) >= amount:
- ws.sendMessage('paid')
-
-
-class WebSocketServer(threading.Thread):
-
- def __init__(self, config: 'SimpleConfig', network: 'Network'):
- threading.Thread.__init__(self)
- self.config = config
- self.network = network
- asyncio.set_event_loop(network.asyncio_loop)
- self.daemon = True
- self.balance_monitor = BalanceMonitor(self.config, self.network)
- self.start()
-
- def run(self):
- asyncio.set_event_loop(self.network.asyncio_loop)
- host = self.config.get('websocket_server')
- port = self.config.get('websocket_port', 9999)
- certfile = self.config.get('ssl_chain')
- keyfile = self.config.get('ssl_privkey')
- self.server = SimpleSSLWebSocketServer(host, port, ElectrumWebSocket, certfile, keyfile)
- self.server.serveforever()
diff --git a/electrum/www b/electrum/www
@@ -0,0 +1 @@
+Subproject commit 538fa508d41512e670fb84970f821a5db71836d9
diff --git a/run_electrum b/run_electrum
@@ -375,15 +375,6 @@ if __name__ == '__main__':
# run daemon
init_plugins(config, 'cmdline')
d = daemon.Daemon(config, fd)
- if config.get('websocket_server'):
- from electrum import websockets
- websockets.WebSocketServer(config, d.network)
- if config.get('requests_dir'):
- path = os.path.join(config.get('requests_dir'), 'index.html')
- if not os.path.exists(path):
- print("Requests directory not configured.")
- print("You can configure it using https://github.com/spesmilo/electrum-merchant")
- sys_exit(1)
d.run_daemon()
sys_exit(0)
else: