pytest: test xkeysend as thoroughly as keysend.
What changed, and why it matters
This commit only adds Python test coverage and a new client-side helper for an existing RPC command called xkeysend. There is no change to the actual Core Lightning daemon code that handles payments, so it cannot by itself introduce a security vulnerability. It is a testing and client-library addition.
No security action required. Review the existing xkeysend RPC implementation separately if assessing its security, since this commit does not modify it.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The diff adds an xkeysend() wrapper in contrib/pyln-client/pyln/client/lightning.py and parameterizes several existing pytest tests to run against both keysend and xkeysend. It also adds one new test (test_xkeysend_layer) exercising xkeysend with askrene layers. No C/lightningd/plugin code is modified. The xkeysend RPC itself is not implemented or changed here; the commit assumes it already exists in the daemon.
Changed components
contrib/pyln-client/pyln/client/lightning.pytests/test_misc.pytests/test_opening.pytests/test_pay.pyInspect captured patch +143 / −32
diff --git a/contrib/pyln-client/pyln/client/lightning.py b/contrib/pyln-client/pyln/client/lightning.py
index 5b72fa64..62a7cb84 100644
--- a/contrib/pyln-client/pyln/client/lightning.py
+++ b/contrib/pyln-client/pyln/client/lightning.py
@@ -1684,3 +1684,24 @@ class LightningRpc(UnixDomainSocketRpc):
"extratlvs": extratlvs,
}
return self.call("keysend", payload)
+
+ def xkeysend(self, destination, amount_msat, maxfee=None,
+ layers=None, retry_for=None, maxdelay=None,
+ extratlvs=None):
+ """
+ """
+ if extratlvs is not None and not isinstance(extratlvs, dict):
+ raise ValueError(
+ "extratlvs is not a dictionary with integer keys and hexadecimal values"
+ )
+
+ payload = {
+ "destination": destination,
+ "amount_msat": amount_msat,
+ "maxfee": maxfee,
+ "layers": layers,
+ "retry_for": retry_for,
+ "maxdelay": maxdelay,
+ "extratlvs": extratlvs,
+ }
+ return self.call("xkeysend", payload)
diff --git a/tests/test_misc.py b/tests/test_misc.py
index 91e8036f..778ad56f 100644
--- a/tests/test_misc.py
+++ b/tests/test_misc.py
@@ -4877,7 +4877,8 @@ def test_preapprove(node_factory, bitcoind, preapprove):
l1.daemon.wait_for_log("preapprove_keysend: check_only=0")
-def test_preapprove_use(node_factory, bitcoind):
+@pytest.mark.parametrize("xkeysend", [False, True])
+def test_preapprove_use(node_factory, bitcoind, xkeysend):
"""Test preapprove calls implicitly made by pay and keysend"""
l1, l2 = node_factory.line_graph(2, opts=[{}, {'dev-hsmd-fail-preapprove': None}])
@@ -4896,9 +4897,15 @@ def test_preapprove_use(node_factory, bitcoind):
# Now keysend.
with pytest.raises(RpcError, match='keysend was declined'):
- l2.rpc.keysend(l1.info['id'], 1000)
+ if xkeysend:
+ l2.rpc.xkeysend(l1.info['id'], 1000)
+ else:
+ l2.rpc.keysend(l1.info['id'], 1000)
with pytest.raises(RpcError, match='keysend was declined'):
- l2.rpc.check('keysend', destination=l1.info['id'], amount_msat=1000)
+ if xkeysend:
+ l2.rpc.check('xkeysend', destination=l1.info['id'], amount_msat=1000)
+ else:
+ l2.rpc.check('keysend', destination=l1.info['id'], amount_msat=1000)
def test_badparam_discretion(node_factory):
diff --git a/tests/test_opening.py b/tests/test_opening.py
index 4954c31b..d1b14801 100644
--- a/tests/test_opening.py
+++ b/tests/test_opening.py
@@ -2144,6 +2144,7 @@ def test_zeroreserve(node_factory, bitcoind):
# Now do some drain tests on c1, as that should be drainable
# completely by l2 being the fundee
l1.rpc.keysend(l2.info['id'], 10 * 7) # Something above dust for sure
+ l1.rpc.xkeysend(l2.info['id'], 10 * 7) # Something above dust for sure
l2.drain(l1)
# Remember that this is the reserve l1 imposed on l2, so l2 can drain completely
diff --git a/tests/test_pay.py b/tests/test_pay.py
index 3d13fb0b..93e45416 100644
--- a/tests/test_pay.py
+++ b/tests/test_pay.py
@@ -3547,7 +3547,8 @@ def test_excluded_adjacent_routehint(node_factory, bitcoind):
l1.rpc.pay(bolt11=inv['bolt11'], maxfeepercent=0, exemptfee=0)
-def test_keysend(node_factory):
+@pytest.mark.parametrize("keysendcmd", ["keysend", "xkeysend"])
+def test_keysend(node_factory, keysendcmd):
amt = 10000
l1, l2, l3, l4 = node_factory.line_graph(
4,
@@ -3555,6 +3556,11 @@ def test_keysend(node_factory):
opts=[{}, {}, {}, {'disable-plugin': 'keysend'}]
)
+ if keysendcmd == 'xkeysend':
+ l1keysend = l1.rpc.xkeysend
+ else:
+ l1keysend = l1.rpc.keysend
+
# The keysend featurebit must be set in the announcement, i.e., l1 should
# learn that l3 supports keysends.
features = l1.rpc.listnodes(l3.info['id'])['nodes'][0]['features']
@@ -3567,10 +3573,10 @@ def test_keysend(node_factory):
# Self-sends are not allowed (see #4438)
with pytest.raises(RpcError, match=r'We are the destination.'):
- l1.rpc.keysend(l1.info['id'], amt)
+ l1keysend(l1.info['id'], amt)
# Send an indirect one from l1 to l3
- l1.rpc.keysend(l3.info['id'], amt)
+ l1keysend(l3.info['id'], amt)
invs = l3.rpc.listinvoices()['invoices']
assert(len(invs) == 1)
@@ -3579,7 +3585,7 @@ def test_keysend(node_factory):
assert(inv['amount_received_msat'] >= Millisatoshi(amt))
# Now send a direct one instead from l1 to l2
- l1.rpc.keysend(l2.info['id'], amt)
+ l1keysend(l2.info['id'], amt)
invs = l2.rpc.listinvoices()['invoices']
assert(len(invs) == 1)
@@ -3588,11 +3594,16 @@ def test_keysend(node_factory):
# And finally try to send a keysend payment to l4, which doesn't
# support it. It MUST fail.
- with pytest.raises(RpcError, match=r"Recipient [0-9a-f]{66} reported an invalid payload"):
- l3.rpc.keysend(l4.info['id'], amt)
+ if keysendcmd == 'xkeysend':
+ with pytest.raises(RpcError, match=r"Destination reported invalid_onion_payload \(likely doesn't support keysend\)"):
+ l3.rpc.xkeysend(l4.info['id'], amt)
+ else:
+ with pytest.raises(RpcError, match=r"Recipient [0-9a-f]{66} reported an invalid payload"):
+ l3.rpc.keysend(l4.info['id'], amt)
-def test_keysend_strip_tlvs(node_factory):
+@pytest.mark.parametrize("keysendcmd", ["keysend", "xkeysend"])
+def test_keysend_strip_tlvs(node_factory, keysendcmd):
"""Use the extratlvs option to deliver a message with sphinx' TLV type, which keysend strips.
"""
amt = 10**7
@@ -3611,11 +3622,18 @@ def test_keysend_strip_tlvs(node_factory):
]
)
+ if keysendcmd == 'xkeysend':
+ l1keysend = l1.rpc.xkeysend
+ l2keysend = l2.rpc.xkeysend
+ else:
+ l1keysend = l1.rpc.keysend
+ l2keysend = l2.rpc.keysend
+
# Make sure listconfigs works here
assert l1.rpc.listconfigs('accept-htlc-tlv-type')['configs']['accept-htlc-tlv-type']['values_int'] == [133773310, 99990]
# l1 is configured to accept, so l2 should still filter them out
- l1.rpc.keysend(l2.info['id'], amt, extratlvs={133773310: 'FEEDC0DE'})
+ l1keysend(l2.info['id'], amt, extratlvs={133773310: 'FEEDC0DE'})
inv = only_one(l2.rpc.listinvoices()['invoices'])
assert not l2.daemon.is_in_log(r'plugin-sphinx-receiver.py.*extratlvs.*133773310.*feedc0de')
@@ -3624,14 +3642,14 @@ def test_keysend_strip_tlvs(node_factory):
l2.rpc.delinvoice(inv['label'], 'paid')
# Now try again with the TLV type in extra_tlvs as string:
- l1.rpc.keysend(l2.info['id'], amt, extratlvs={133773310: b'hello there'.hex()})
+ l1keysend(l2.info['id'], amt, extratlvs={133773310: b'hello there'.hex()})
inv = only_one(l2.rpc.listinvoices()['invoices'])
assert inv['description'] == 'keysend: hello there'
l2.daemon.wait_for_log('Keysend payment uses illegal even field 133773310: stripping')
l2.rpc.delinvoice(inv['label'], 'paid')
# We can (just!) fit a giant description in.
- l1.rpc.keysend(l2.info['id'], amt, extratlvs={133773310: (b'a' * 1100).hex()})
+ l1keysend(l2.info['id'], amt, extratlvs={133773310: (b'a' * 1100).hex()})
inv = only_one(l2.rpc.listinvoices()['invoices'])
assert inv['description'] == 'keysend: ' + 'a' * 1100
l2.rpc.delinvoice(inv['label'], 'paid')
@@ -3642,14 +3660,14 @@ def test_keysend_strip_tlvs(node_factory):
More info
"""
# Since we're at it, use this to test string-keyed TLVs
- l1.rpc.keysend(l2.info['id'], amt, extratlvs={"133773310": bytes(ksinfo, encoding='utf8').hex()})
+ l1keysend(l2.info['id'], amt, extratlvs={"133773310": bytes(ksinfo, encoding='utf8').hex()})
inv = only_one(l2.rpc.listinvoices()['invoices'])
assert inv['description'] == 'keysend: ' + ksinfo
l2.daemon.wait_for_log('Keysend payment uses illegal even field 133773310: stripping')
# Now reverse the direction. l1 accepts 133773310, but filters out
# other even unknown types (like 133773312).
- l2.rpc.keysend(l1.info['id'], amt, extratlvs={
+ l2keysend(l1.info['id'], amt, extratlvs={
"133773310": b"helloworld".hex(), # This one is allowlisted
"133773312": b"filterme".hex(), # This one will get stripped
})
@@ -3707,32 +3725,91 @@ def test_keysend_routehint(node_factory):
assert(inv['amount_received_msat'] >= Millisatoshi(amt))
-def test_keysend_maxfee(node_factory):
+def test_xkeysend_layer(node_factory):
+ """Test whether we can deliver a keysend by adding layer (vs keyhint's routehints)
+ """
+ amt = 10000
+ l1, l2 = node_factory.line_graph(2, wait_for_announce=True)
+ l3 = node_factory.get_node()
+ l2.connect(l3)
+ l2.fundchannel(l3, announce_channel=False)
+
+ dest = l3.info['id']
+ chan = only_one(l3.rpc.listpeerchannels()['channels'])
+ l1.rpc.askrene_create_layer('test_xkeysend_layer1')
+ l1.rpc.askrene_create_channel('test_xkeysend_layer1',
+ l2.info['id'],
+ dest,
+ chan['alias']['remote'],
+ '1000000sat')
+ l1.rpc.askrene_update_channel(layer='test_xkeysend_layer1',
+ short_channel_id_dir=f"{chan['alias']['remote']}/{chan['direction'] ^ 1}",
+ htlc_minimum_msat=100,
+ htlc_maximum_msat=900000000,
+ fee_base_msat=1,
+ fee_proportional_millionths=2,
+ cltv_expiry_delta=18,
+ enabled=True)
+
+ # Dummy one
+ l1.rpc.askrene_create_layer('test_xkeysend_layer2')
+ l1.rpc.askrene_create_channel('test_xkeysend_layer2',
+ l2.info['id'],
+ '02' * 33,
+ '1x2x3',
+ '1000000sat')
+ for scidd in ('1x2x3/0', '1x2x3/1'):
+ l1.rpc.askrene_update_channel(layer='test_xkeysend_layer2',
+ short_channel_id_dir=scidd,
+ htlc_minimum_msat=100,
+ htlc_maximum_msat=900000000,
+ fee_base_msat=1,
+ fee_proportional_millionths=2,
+ cltv_expiry_delta=18,
+ enabled=True)
+
+ # Without any extra information we should fail:
+ with pytest.raises(RpcError):
+ l1.rpc.call("xkeysend", payload={'destination': dest, 'amount_msat': amt})
+
+ # We should also fail with only non-useful layers:
+ with pytest.raises(RpcError):
+ l1.rpc.call("xkeysend", payload={'destination': dest, 'amount_msat': amt, 'layers': ['test_xkeysend_layer2']})
+
+ l1.rpc.call("xkeysend", payload={'destination': dest, 'amount_msat': amt, 'layers': ['test_xkeysend_layer1', 'test_xkeysend_layer2']})
+ inv = only_one(l3.rpc.listinvoices()['invoices'])
+ assert inv['amount_received_msat'] == amt
+
+
+@pytest.mark.parametrize("keysendcmd", ["keysend", "xkeysend"])
+def test_keysend_maxfee(node_factory, keysendcmd):
l1, l2, l3 = node_factory.line_graph(
3,
wait_for_announce=True,
opts=[{}, {'fee-base': 50, 'fee-per-satoshi': 0}, {}]
)
- # We should fail because maxfee and exemptfee cannot be set simultaneously.
- with pytest.raises(RpcError):
- l1.rpc.call("keysend", payload={'destination': l3.info['id'], 'amount_msat': 1, 'maxfee': 1, 'exemptfee': 5000})
+ if keysendcmd == 'keysend':
+ # We should fail because maxfee and exemptfee cannot be set simultaneously.
+ with pytest.raises(RpcError):
+ l1.rpc.call(keysendcmd, payload={'destination': l3.info['id'], 'amount_msat': 1, 'maxfee': 1, 'exemptfee': 5000})
- # We should fail because maxfee and maxfeepercent cannot be set simultaneously.
- with pytest.raises(RpcError):
- l1.rpc.call("keysend", payload={'destination': l3.info['id'], 'amount_msat': 1, 'maxfee': 1, 'maxfeepercent': 0.0001})
+ # We should fail because maxfee and maxfeepercent cannot be set simultaneously.
+ with pytest.raises(RpcError):
+ l1.rpc.call(keysendcmd, payload={'destination': l3.info['id'], 'amount_msat': 1, 'maxfee': 1, 'maxfeepercent': 0.0001})
# We should fail because 50msat base fee on l2 exceeds maxfee of 1msat.
- with pytest.raises(RpcError):
- l1.rpc.call("keysend", payload={'destination': l3.info['id'], 'amount_msat': 1, 'maxfee': 1})
+ with pytest.raises(RpcError, match='Fee exceeds our fee budget|excessive cost'):
+ l1.rpc.call(keysendcmd, payload={'destination': l3.info['id'], 'amount_msat': 1, 'maxfee': 1})
assert len(l3.rpc.listinvoices()['invoices']) == 0
# Perform a normal keysend with maxfee.
- l1.rpc.call("keysend", payload={'destination': l3.info['id'], 'amount_msat': 1, 'maxfee': 50})
+ l1.rpc.call(keysendcmd, payload={'destination': l3.info['id'], 'amount_msat': 1, 'maxfee': 50})
assert len(l3.rpc.listinvoices()['invoices']) == 1
-def test_keysend_description_size_limit(node_factory):
+@pytest.mark.parametrize("keysendcmd", ["keysend", "xkeysend"])
+def test_keysend_description_size_limit(node_factory, keysendcmd):
"""
Test keysend description handling near BOLT11 field size limits.
@@ -3746,6 +3823,11 @@ def test_keysend_description_size_limit(node_factory):
prefix = 'keysend: '
base_len = len(prefix)
+ if keysendcmd == 'xkeysend':
+ l1keysend = l1.rpc.xkeysend
+ else:
+ l1keysend = l1.rpc.keysend
+
tlv_lens = [638, 639, 640, 641, 1022, 1023, 1024]
expected = set()
for tlv_payload_length in tlv_lens:
@@ -3755,7 +3837,7 @@ def test_keysend_description_size_limit(node_factory):
expected.add(prefix + "a" * body_len)
# Send keysend payment with test payload
- l1.rpc.keysend(l2.info["id"], amt, extratlvs={7629169: tlv_payload})
+ l1keysend(l2.info["id"], amt, extratlvs={7629169: tlv_payload})
assert set(inv['description'] for inv in l2.rpc.listinvoices()["invoices"]) == expected
assert all(inv['amount_received_msat'] == amt for inv in l2.rpc.listinvoices()["invoices"])
@@ -4091,15 +4173,15 @@ def test_delpay_mixed_status(node_factory, bitcoind):
assert len(l1.rpc.listsendpays()['payments']) == 1
-def test_listpay_result_with_paymod(node_factory, bitcoind):
+@pytest.mark.parametrize("keysendcmd", ["keysend", "xkeysend"])
+def test_listpay_result_with_paymod(node_factory, bitcoind, keysendcmd):
"""
The object of this test is to verify the correct behavior
of the RPC command listpay e with two different type of
payment, such as: keysend (without invoice) and pay (with invoice).
- l1 -> keysend -> l2
- l2 -> pay invoice -> l3
+ l1 -> pay invoice -> l2
+ l2 -> keysend -> l3
"""
-
amount_sat = 10 ** 6
l1, l2, l3 = node_factory.line_graph(3, wait_for_announce=True)
@@ -4107,7 +4189,7 @@ def test_listpay_result_with_paymod(node_factory, bitcoind):
invl2 = l2.rpc.invoice(amount_sat * 2, "inv_l2", "inv_l2")
l1.rpc.pay(invl2['bolt11'])
- l2.rpc.keysend(l3.info['id'], amount_sat * 2, "keysend_l3")
+ l2.rpc.call(keysendcmd, payload=[l3.info['id'], amount_sat * 2])
assert 'bolt11' in l1.rpc.listpays()['pays'][0]
assert 'bolt11' not in l2.rpc.listpays()['pays'][0]
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.