pytest: test xpay notifications.
What changed, and why it matters
This commit only adds new automated tests for the xpay feature's notification system. It does not change any production code, so it cannot introduce a security vulnerability or fix one. The tests verify that plugins receive messages when a payment starts and ends, including failure cases.
No security action required. This is a test-only commit.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The diff adds two new notification subscriptions (pay_part_start and pay_part_end) to an existing test plugin, and adds a pytest test case (test_attempt_notifications) that exercises the xpay RPC command against a three-node test network. The test asserts the exact payload structure of notifications for successful, final-node-failure, and intermediary-node-failure scenarios. No C-lightning core code is modified.
Changed components
tests/plugins/custom_notifications.pytests/test_xpay.pyInspect captured patch +60 / −0
diff --git a/tests/plugins/custom_notifications.py b/tests/plugins/custom_notifications.py
index a3fa9b9a..8ece850d 100755
--- a/tests/plugins/custom_notifications.py
+++ b/tests/plugins/custom_notifications.py
@@ -34,6 +34,16 @@ def on_pay_success(origin, payload, **kwargs):
)
+@plugin.subscribe("pay_part_start")
+def on_pay_part_start(origin, payload, **kwargs):
+ plugin.log("Got pay_part_start: {}".format(payload))
+
+
+@plugin.subscribe("pay_part_end")
+def on_pay_part_end(origin, payload, **kwargs):
+ plugin.log("Got pay_part_end: {}".format(payload))
+
+
@plugin.subscribe("ididntannouncethis")
def on_faulty_emit(origin, payload, **kwargs):
"""We should never receive this as it gets dropped.
diff --git a/tests/test_xpay.py b/tests/test_xpay.py
index f99764ea..9022e28d 100644
--- a/tests/test_xpay.py
+++ b/tests/test_xpay.py
@@ -832,3 +832,53 @@ lightning-cli pay lni1qqgv5nalmz08ukj4av074kyk6pepq93pqvvhnlnvurnfanndnxjtcjnmxr
# This doesn't!
l1.rpc.xpay(inv)
l1.daemon.wait_for_log(f'Adding HTLC 1 amount=15002msat cltv={110 + 1 + 100 + 200 + 400}')
+
+
+def test_attempt_notifications(node_factory):
+ plugin_path = os.path.join(os.getcwd(), 'tests/plugins/custom_notifications.py')
+ l1, l2, l3 = node_factory.line_graph(3, wait_for_announce=True,
+ opts=[{"plugin": plugin_path}, {}, {}])
+
+ scid12 = only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['short_channel_id']
+ scid12_dir = only_one(l1.rpc.listpeerchannels(l2.info['id'])['channels'])['direction']
+ scid23 = only_one(l2.rpc.listpeerchannels(l3.info['id'])['channels'])['short_channel_id']
+ scid23_dir = only_one(l2.rpc.listpeerchannels(l3.info['id'])['channels'])['direction']
+ inv1 = l3.rpc.invoice(5000000, 'test_attempt_notifications1', 'test_attempt_notifications1')
+ l1.rpc.xpay(inv1['bolt11'])
+
+ line = l1.daemon.wait_for_log("plugin-custom_notifications.py: Got pay_part_start: ")
+ regex = r".*Got pay_part_start: \{'payment_hash': '" + inv1['payment_hash'] + r"', 'groupid': [0-9]*, 'partid': 1, 'total_payment_msat': 5000000, 'attempt_msat': 5000000, 'hops': \[\{'next_node': '" + l2.info['id'] + r"', 'short_channel_id': '" + scid12 + r"', 'direction': " + str(scid12_dir) + r", 'channel_in_msat': 5000051, 'channel_out_msat': 5000051\}, \{'next_node': '" + l3.info['id'] + r"', 'short_channel_id': '" + scid23 + r"', 'direction': " + str(scid23_dir) + r", 'channel_in_msat': 5000051, 'channel_out_msat': 5000000\}\]\}"
+ assert re.match(regex, line)
+
+ # Note, duration always has 9 decimals, EXCEPT that the python code interprets it, so if the last digit is a 0 it will only print 8.
+ line = l1.daemon.wait_for_log("plugin-custom_notifications.py: Got pay_part_end: ")
+ regex = r".*Got pay_part_end: \{'status': 'success', 'duration': [0-9]*\.[0-9]*, 'payment_hash': '" + inv1['payment_hash'] + r"', 'groupid': [0-9]*, 'partid': 1\}"
+ assert re.match(regex, line)
+
+ inv2 = l3.rpc.invoice(10000000, 'test_attempt_notifications2', 'test_attempt_notifications2')
+ l3.rpc.delinvoice('test_attempt_notifications2', "unpaid")
+
+ # Final node failure
+ with pytest.raises(RpcError, match=r"Destination said it doesn't know invoice: incorrect_or_unknown_payment_details"):
+ l1.rpc.xpay(inv2['bolt11'])
+
+ line = l1.daemon.wait_for_log("plugin-custom_notifications.py: Got pay_part_start: ")
+ regex = r".*Got pay_part_start: \{'payment_hash': '" + inv2['payment_hash'] + r"', 'groupid': [0-9]*, 'partid': 1, 'total_payment_msat': 10000000, 'attempt_msat': 10000000, 'hops': \[\{'next_node': '" + l2.info['id'] + r"', 'short_channel_id': '" + scid12 + r"', 'direction': " + str(scid12_dir) + r", 'channel_in_msat': 10000101, 'channel_out_msat': 10000101\}, \{'next_node': '" + l3.info['id'] + r"', 'short_channel_id': '" + scid23 + r"', 'direction': " + str(scid23_dir) + r", 'channel_in_msat': 10000101, 'channel_out_msat': 10000000\}\]\}"
+ assert re.match(regex, line)
+
+ line = l1.daemon.wait_for_log("plugin-custom_notifications.py: Got pay_part_end: ")
+ regex = r".*Got pay_part_end: \{'status': 'failure', 'payment_hash': '" + inv2['payment_hash'] + r"', 'groupid': [0-9]*, 'partid': 1, 'failed_msg': '400f00000000009896800000006c', 'duration': [0-9]*\.[0-9]*, 'failed_node_id': '" + l3.info['id'] + r"', 'error_code': 16399, 'error_message': 'incorrect_or_unknown_payment_details'\}"
+ assert re.match(regex, line)
+
+ # Intermediary node failure
+ l3.stop()
+ with pytest.raises(RpcError, match=r"Failed after 1 attempts"):
+ l1.rpc.xpay(inv2['bolt11'])
+
+ line = l1.daemon.wait_for_log("plugin-custom_notifications.py: Got pay_part_start: ")
+ regex = r".*Got pay_part_start: \{'payment_hash': '" + inv2['payment_hash'] + r"', 'groupid': [0-9]*, 'partid': 1, 'total_payment_msat': 10000000, 'attempt_msat': 10000000, 'hops': \[\{'next_node': '" + l2.info['id'] + r"', 'short_channel_id': '" + scid12 + r"', 'direction': " + str(scid12_dir) + r", 'channel_in_msat': 10000101, 'channel_out_msat': 10000101\}, \{'next_node': '" + l3.info['id'] + r"', 'short_channel_id': '" + scid23 + r"', 'direction': " + str(scid23_dir) + r", 'channel_in_msat': 10000101, 'channel_out_msat': 10000000\}\]\}"
+ assert re.match(regex, line)
+
+ line = l1.daemon.wait_for_log("plugin-custom_notifications.py: Got pay_part_end: ")
+ regex = r".*Got pay_part_end: \{'status': 'failure', 'payment_hash': '" + inv2['payment_hash'] + r"', 'groupid': [0-9]*, 'partid': 1, 'failed_msg': '1007[a-f0-9]*', 'duration': [0-9]*\.[0-9]*, 'failed_node_id': '" + l2.info['id'] + r"', 'failed_short_channel_id': '" + scid23 + r"', 'failed_direction': " + str(scid23_dir) + r", 'error_code': 4103, 'error_message': 'temporary_channel_failure'\}"
+ assert re.match(regex, line)
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.