Skip to content

Commit cc855af

Browse files
committed
donations: actual integration test and several fixes/improvements
- integration test doing a donation and checking if it shows up - production WSGI server waitress instead of dev server - CLN rpc fixes, decodepay -> decode, wait if listinvoies is not yet populated, msat field name fixed - qrcode dependency was missing pil to render qr codes - multithreading was no longer working for some reason, replaced with threading - fix typo in template copyrite -> copyright - formatted template code
1 parent 9a9adc5 commit cc855af

File tree

5 files changed

+640
-224
lines changed

5 files changed

+640
-224
lines changed

donations/donations.py

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@
33
# /// script
44
# requires-python = ">=3.9.2"
55
# dependencies = [
6-
# "qrcode>=7.4.2",
6+
# "qrcode[pil]>=7.4.2",
77
# "flask>=2.3.3",
88
# "pyln-client>=24.11",
99
# "flask-bootstrap>=3.3.7.1",
1010
# "flask-wtf>=1.2.1",
1111
# "werkzeug>=3.0.6",
1212
# "wtforms>=3.1.2",
13+
# "waitress>=3.0.2",
1314
# ]
1415
# ///
1516

@@ -31,8 +32,10 @@
3132
"""
3233

3334
import base64
34-
import multiprocessing
3535
import qrcode
36+
import threading
37+
import logging
38+
import sys
3639

3740

3841
from flask import Flask, render_template
@@ -43,6 +46,7 @@
4346
from random import random
4447
from wtforms import StringField, SubmitField, IntegerField
4548
from wtforms.validators import DataRequired, NumberRange
49+
from waitress.server import create_server
4650

4751

4852
plugin = Plugin()
@@ -79,7 +83,10 @@ def make_base64_qr_code(bolt11):
7983

8084
def ajax(label):
8185
global plugin
82-
msg = plugin.rpc.listinvoices(label)["invoices"][0]
86+
invoices = plugin.rpc.listinvoices(label)["invoices"]
87+
if len(invoices) == 0:
88+
return "waiting"
89+
msg = invoices[0]
8390
if msg["status"] == "paid":
8491
return "Your donation has been received and is well appricated."
8592
return "waiting"
@@ -105,8 +112,8 @@ def donation_form():
105112
if invoice["label"].startswith("ln-plugin-donations-"):
106113
# FIXME: change to paid after debugging
107114
if invoice["status"] == "paid":
108-
bolt11 = plugin.rpc.decodepay(invoice["bolt11"])
109-
satoshis = int(bolt11["msatoshi"]) // 1000
115+
bolt11 = plugin.rpc.decode(invoice["bolt11"])
116+
satoshis = int(bolt11["amount_msat"]) // 1000
110117
description = bolt11["description"]
111118
ts = bolt11["created_at"]
112119
donations.append((ts, satoshis, description))
@@ -126,14 +133,25 @@ def donation_form():
126133
)
127134

128135

129-
def worker(port):
136+
def worker(port, ready_event):
130137
app = Flask("donations")
131138
# FIXME: use hexlified hsm secret or something else
132139
app.config["SECRET_KEY"] = "you-will-never-guess-this"
133140
app.add_url_rule("/donation", "donation", donation_form, methods=["GET", "POST"])
134141
app.add_url_rule("/is_invoice_paid/<label>", "ajax", ajax)
135142
Bootstrap(app)
136-
app.run(host="0.0.0.0", port=port)
143+
144+
server = create_server(app, host="*", port=port)
145+
146+
app.logger.setLevel(logging.INFO)
147+
logging.getLogger("waitress").setLevel(logging.INFO)
148+
149+
app.logger.info(f"Starting donation server on port {port} on all addresses")
150+
151+
jobs[port]["server"] = server
152+
ready_event.set()
153+
154+
server.run()
137155
return
138156

139157

@@ -144,21 +162,38 @@ def start_server(port):
144162
if port in jobs:
145163
return False, "server already running"
146164

147-
p = multiprocessing.Process(
148-
target=worker, args=[port], name="server on port {}".format(port)
165+
ready_event = threading.Event()
166+
thread = threading.Thread(
167+
target=worker,
168+
args=[port, ready_event],
169+
name=f"server on port {port}",
170+
daemon=False,
149171
)
150-
p.daemon = True
151172

152-
jobs[port] = p
153-
p.start()
173+
jobs[port] = {"thread": thread, "ready": ready_event}
174+
thread.start()
175+
176+
ready_event.wait(timeout=5.0)
177+
if "server" not in jobs[port]:
178+
return False, "server failed to start in time"
154179

155180
return True
156181

157182

183+
@plugin.subscribe("shutdown")
184+
def on_rpc_command_callback(plugin, **kwargs):
185+
for port in list(jobs.keys()):
186+
stop_server(port)
187+
sys.exit()
188+
189+
158190
def stop_server(port):
159191
if port in jobs:
160-
jobs[port].terminate()
161-
jobs[port].join()
192+
server = jobs[port]["server"]
193+
thread = jobs[port]["thread"]
194+
server.close()
195+
if thread.is_alive():
196+
thread.join(timeout=2.0)
162197
del jobs[port]
163198
return True
164199
else:

donations/pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ readme = "README.md"
88
requires-python = ">=3.9.2"
99

1010
dependencies = [
11-
"qrcode>=7.4.2",
11+
"qrcode[pil]>=7.4.2",
1212
"flask>=2.3.3",
1313
"pyln-client>=24.11",
1414
"flask-bootstrap>=3.3.7.1",
1515
"flask-wtf>=1.2.1",
1616
"werkzeug>=3.0.6",
1717
"wtforms>=3.1.2",
18+
"waitress>=3.0.2",
1819
]
1920

2021
[dependency-groups]
@@ -25,4 +26,5 @@ dev = [
2526
"pyln-testing>=24.11",
2627
"pyln-client>=24.11",
2728
"pyln-proto>=24.11",
29+
"requests>=2.32.5",
2830
]

donations/templates/donation.html

Lines changed: 51 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -2,78 +2,83 @@
22
{% import 'bootstrap/wtf.html' as wtf %}
33

44
{% block title %}
5-
Lightning Donations
5+
Lightning Donations
66
{% endblock %}
77

88
{% block navbar %}
9-
<nav class="navbar navbar-default">
10-
... navigation bar here (see complete code on GitHub) ...
11-
</nav>
9+
<nav class="navbar navbar-default">
10+
... navigation bar here (see complete code on GitHub) ...
11+
</nav>
1212
{% endblock %}
1313

1414
{% block content %}
15-
<div class="container">
16-
<h1>Leave a donation to support my work!</h1>
17-
{% if bolt11 %}
18-
<div id="target_div">
19-
<div>
20-
<input type="text" value="{{bolt11}}" id="bolt11">
21-
<button onclick="copyFunction()">Copy invoice</button>
22-
</div>
23-
<div>
24-
<img src="data:image/png;base64,{{qr}}" />
25-
</div>
26-
</div>
15+
<div class="container">
16+
<h1>Leave a donation to support my work!</h1>
17+
{% if bolt11 %}
18+
<div id="target_div">
19+
<div>
20+
<input type="text" value="{{bolt11}}" id="bolt11">
21+
<button onclick="copyFunction()">Copy invoice</button>
22+
</div>
23+
<div>
24+
<img src="data:image/png;base64,{{qr}}" />
25+
</div>
26+
</div>
2727
{% else %}
28-
{{ wtf.quick_form(form) }}
28+
{{ wtf.quick_form(form) }}
2929
{% endif %}
3030
<h2>Most recent donations & comments</h2>
3131
<ul>
3232
{% for item in donations %}
33-
<li>{{ item[1] }} Satoshi. Message: {{ item[2] }}</a></li>
33+
<li>{{ item[1] }} Satoshi. Message: {{ item[2] }}</a></li>
3434
{% endfor %}
35-
</ul>
36-
<p>The above texts come from a community of unknown users. If you think they violate against your copyrite please <a href="https://www.rene-pickhardt.de/imprint" rel="nofollow"> contact me</a> so that I can remove those comments. According to the German law I am not responsible for Copyright violations rising from user generated content unless you notify me and I don't react.</p>
35+
</ul>
36+
<p>The above texts come from a community of unknown users. If you think they violate against your copyright please <a
37+
href="https://www.rene-pickhardt.de/imprint" rel="nofollow"> contact me</a> so that I can remove those comments.
38+
According to the German law I am not responsible for Copyright violations rising from user generated content unless
39+
you notify me and I don't react.</p>
3740

38-
<hr>
39-
<p>
40-
c-lightning invoice query service for donations and spontanious payments is brought to you by
41-
<a href="https://ln.rene-pickhardt.de">Rene Pickhardt</a>.</p>
41+
<hr>
42+
<p>
43+
c-lightning invoice query service for donations and spontanious payments is brought to you by
44+
<a href="https://ln.rene-pickhardt.de">Rene Pickhardt</a>.
45+
</p>
4246

43-
<p>
44-
If you want to learn more about the Lightning network (for beginners and for developers) check out
45-
<a href="https://www.youtube.com/user/RenePickhardt">his youtube channel</a>.
46-
</p>
47-
<p>
48-
Find the source code for this plugin at: <a href="https://github.com/ElementsProject/lightning/tree/master/contrib/plugins/donations">https://github.com/ElementsProject/lightning/tree/master/contrib/plugins/donations</a>
49-
</p>
47+
<p>
48+
If you want to learn more about the Lightning network (for beginners and for developers) check out
49+
<a href="https://www.youtube.com/user/RenePickhardt">his youtube channel</a>.
50+
</p>
51+
<p>
52+
Find the source code for this plugin at: <a
53+
href="https://github.com/ElementsProject/lightning/tree/master/contrib/plugins/donations">https://github.com/ElementsProject/lightning/tree/master/contrib/plugins/donations</a>
54+
</p>
5055
</div>
5156
{% endblock %}
5257
{% block scripts %}
5358
{{super()}}
5459
<script>
55-
var interval = null;
56-
$(document).on('ready',function(){
57-
interval = setInterval(updateDiv,3000);
58-
});
60+
var interval = null;
61+
$(document).on('ready', function () {
62+
interval = setInterval(updateDiv, 3000);
63+
});
5964

60-
function updateDiv(){
65+
function updateDiv() {
6166
$.ajax({
62-
url: '/is_invoice_paid/{{label}}',
63-
success: function(data){
64-
if (data != "waiting") {
65-
var tc = document.getElementById("target_div");
66-
tc.innerHTML = data;
67-
clearInterval(interval);
68-
}
67+
url: '/is_invoice_paid/{{label}}',
68+
success: function (data) {
69+
if (data != "waiting") {
70+
var tc = document.getElementById("target_div");
71+
tc.innerHTML = data;
72+
clearInterval(interval);
6973
}
74+
}
7075
});
71-
}
76+
}
7277

73-
function copyFunction() {
78+
function copyFunction() {
7479
document.getElementById("bolt11").select();
7580
document.execCommand("copy");
7681
alert("Copied invoice to clipboard.");
77-
}
82+
}
7883
</script>
7984
{% endblock %}

donations/test_donations.py

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import os
2+
import re
3+
import time
4+
import pytest
25
from pyln.testing.fixtures import * # noqa: F401,F403
3-
from ephemeral_port_reserve import reserve # type: ignore
6+
import requests
47

58
plugin_path = os.path.join(os.path.dirname(__file__), "donations.py")
69

710

811
def test_donation_starts(node_factory):
9-
l1 = node_factory.get_node(allow_warning=True)
12+
l1 = node_factory.get_node()
1013
# Test dynamically
1114
l1.rpc.plugin_start(plugin_path)
1215
l1.rpc.plugin_stop(plugin_path)
@@ -19,10 +22,61 @@ def test_donation_starts(node_factory):
1922

2023
def test_donation_server(node_factory):
2124
pluginopt = {"plugin": plugin_path, "donations-autostart": False}
22-
l1 = node_factory.get_node(options=pluginopt, allow_warning=True)
23-
port = reserve()
25+
l1 = node_factory.get_node(options=pluginopt)
26+
port = node_factory.get_unused_port()
2427
l1.rpc.donationserver("start", port)
25-
l1.daemon.wait_for_log("plugin-donations.py:.*Serving Flask app 'donations'")
26-
l1.daemon.wait_for_log("plugin-donations.py:.*Running on all addresses")
28+
l1.daemon.wait_for_log(
29+
f"plugin-donations.py:.*Starting donation server on port {port} on all addresses"
30+
)
31+
32+
session = requests.Session()
33+
34+
response = session.get(f"http://127.0.0.1:{port}/donation")
35+
assert response.status_code == 200
36+
assert "Leave a donation" in response.text
37+
38+
match = re.search(r'name="csrf_token"[^>]*value="([^"]+)"', response.text)
39+
assert match, "Could not find CSRF token"
40+
csrf_token = match.group(1)
41+
42+
response = session.post(
43+
f"http://127.0.0.1:{port}/donation",
44+
data={
45+
"csrf_token": csrf_token,
46+
"amount": "1000",
47+
"description": "Test donation from pytest",
48+
},
49+
)
50+
assert response.status_code == 200
51+
assert "The CSRF token is missing" not in response.text
52+
53+
assert "data:image/png" in response.text
54+
55+
match = re.search(r'value="(lnbc[^"]+)"', response.text)
56+
assert match, "No bolt11 invoice found in response"
57+
bolt11 = match.group(1)
58+
l1.rpc.call("xpay", [bolt11])
59+
label = l1.rpc.call("listinvoices", {"invstring": bolt11})["invoices"][0]["label"]
60+
61+
max_attempts = 10
62+
for attempt in range(max_attempts):
63+
response = session.get(f"http://127.0.0.1:{port}/is_invoice_paid/{label}")
64+
65+
if response.text != "waiting":
66+
# Payment detected!
67+
assert (
68+
"Your donation has been received and is well appricated."
69+
in response.text
70+
)
71+
break
72+
73+
time.sleep(1)
74+
else:
75+
pytest.fail("Payment was not detected after 10 seconds")
76+
77+
response = session.get(f"http://127.0.0.1:{port}/donation")
78+
assert "1000 Satoshi" in response.text
79+
assert "Test donation from pytest" in response.text
80+
2781
msg = l1.rpc.donationserver("stop", port)
2882
assert msg == f"stopped server on port {port}"

0 commit comments

Comments
 (0)