From 6dc8523b9457e41b5959fcc80e1dfa4aca0e6ddd Mon Sep 17 00:00:00 2001 From: Lagrang3 Date: Tue, 15 Sep 2026 10:45:48 +0100 Subject: [PATCH] sendpay: idempotent success on replay of completed self-payments According to the documentation of sendpay: Calls to sendpay with the same payment_hash, amount_msat, and destination as a previous successful payment (even if a different route or partid) will return immediately with success. A self payment is a special case, but the destination being always ourselves should follow the same rule. Changelog-Fixed: sendpay: immediate success when replaying a completed self payment. Signed-off-by: Lagrang3 --- lightningd/pay.c | 13 +++++++++++++ tests/test_pay.py | 46 +++++++++++++++++++++++++++++++++++++++------- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/lightningd/pay.c b/lightningd/pay.c index 17e9080948a0..19b692894901 100644 --- a/lightningd/pay.c +++ b/lightningd/pay.c @@ -1485,9 +1485,22 @@ static struct command_result *self_payment(struct lightningd *ld, const u8 *payment_metadata) { struct wallet_payment *payment; + const struct wallet_payment *prev_payment; const struct invoice_details *inv; u64 inv_dbid; const char *err; + struct command_result *ret; + assert(partid == 0); + + /* Reconcile this with previous attempts */ + ret = check_progress(ld, cmd, rhash, msat, msat, partid, groupid, + &ld->our_nodeid, &prev_payment); + if (ret) + return ret; + + /* Previous payment success is defined to be idempotent */ + if (prev_payment) + return sendpay_success(cmd, prev_payment, NULL); payment = wallet_add_payment(tmpctx, ld->wallet, diff --git a/tests/test_pay.py b/tests/test_pay.py index 72a15855e0a8..2bf7b1ac1473 100644 --- a/tests/test_pay.py +++ b/tests/test_pay.py @@ -5738,7 +5738,15 @@ def test_self_sendpay(node_factory): # Bad payment_secret with pytest.raises(RpcError, match="Attempt to pay .* with wrong payment_secret"): - l1.rpc.sendpay([], inv['payment_hash'], label='selfpay-badimage', bolt11=inv['bolt11'], payment_secret='00' * 32, amount_msat='100000sat') + l1.rpc.sendpay([], inv['payment_hash'], label='selfpay-badimage', + bolt11=inv['bolt11'], payment_secret='00' * 32, + amount_msat='100000sat', groupid=1111) + + # retry with the same partid and groupid + with pytest.raises(RpcError, match=f"There already is a payment with payment_hash={inv['payment_hash']}, groupid=1111, partid=0."): + l1.rpc.sendpay([], inv['payment_hash'], label='selfpay-badimage', + bolt11=inv['bolt11'], payment_secret='00' * 32, + amount_msat='100000sat', groupid=1111) # Expired time.sleep(2) @@ -5746,15 +5754,39 @@ def test_self_sendpay(node_factory): l1.rpc.sendpay([], inv_expires['payment_hash'], label='selfpay-badimage', bolt11=inv_expires['bolt11'], payment_secret=inv['payment_secret'], amount_msat='1btc') # This one works! - l1.rpc.sendpay([], inv['payment_hash'], label='selfpay', bolt11=inv['bolt11'], payment_secret=inv['payment_secret'], amount_msat='100000sat') + self_groupid = 0 + l1.rpc.sendpay([], inv['payment_hash'], label='selfpay', + bolt11=inv['bolt11'], payment_secret=inv['payment_secret'], + amount_msat='100000sat', groupid=self_groupid) + + # from sendpay documentation: + # Calls to sendpay with the same payment_hash, amount_msat, + # and destination as a previous successful payment + # (even if a different route or partid) will return immediately with success. + l1.rpc.sendpay([], inv['payment_hash'], label='selfpay', + bolt11=inv['bolt11'], + payment_secret=inv['payment_secret'], + amount_msat='100000sat') + + # same groupid and same partid + l1.rpc.sendpay([], inv['payment_hash'], label='selfpay', + bolt11=inv['bolt11'], + payment_secret=inv['payment_secret'], + amount_msat='100000sat', + groupid=self_groupid) + + # a different amount + with pytest.raises(RpcError, match="Already succeeded with amount 100000000msat"): + l1.rpc.sendpay([], inv['payment_hash'], label='selfpay', + bolt11=inv['bolt11'], + payment_secret=inv['payment_secret'], + amount_msat='200000sat') assert only_one(l1.rpc.listinvoices(payment_hash=inv['payment_hash'])['invoices'])['status'] == 'paid' - # Only one is complete. - assert [p['status'] for p in l1.rpc.listsendpays()['payments'] if p['status'] != 'failed'] == ['complete'] - # Can't pay paid one already paid! - with pytest.raises(RpcError, match="Already paid or expired invoice"): - l1.rpc.sendpay([], inv['payment_hash'], label='selfpay', bolt11=inv['bolt11'], payment_secret=inv['payment_secret'], amount_msat='100000sat') + # We have succeeded several calls to sendpay for the same invoice but only + # one payment was executed and recorded. + assert [p['status'] for p in l1.rpc.listsendpays()['payments'] if p['status'] != 'failed'] == ['complete'] def test_strip_lightning_suffix_from_inv(node_factory):