lightningd: add invoice_amt to the htlc_accepted hook
What changed, and why it matters
This commit adds a new plugin hook capability that lets a plugin override the expected invoice amount when a Lightning payment arrives. It is a deliberate feature, not a bug fix, and the commit message and documentation describe it as useful for accepting smaller payments than originally invoiced. There is no direct evidence this change introduces a vulnerability, but any hook that can override financial validation could be misused if a malicious or buggy plugin is installed.
Treat this as a normal feature review. Operators should ensure only trusted plugins are granted access to the `htlc_accepted` hook, since a compromised plugin could alter payment acceptance logic. Developers should verify that `invoice_msat` cannot be used to bypass other invariants such as overpayment limits or MPP total matching.
Security signals we found
New hook override affects payment amount validation
Plugin-provided value is passed directly into invoice payment check
No explicit sanitization or bounds check on invoice_msat beyond JSON parsing
Feature is intentionally exposed to plugins via documented API
Evidence from the diff
The change extends the htlc_accepted hook response parsing to accept an optional invoice_msat field. When present, this value is passed through htlc_set_add_ to invoice_check_payment as the invoice_msat_override parameter, replacing the invoice’s stored amount during payment validation. The implementation adds the field to hook payload deserialization, updates call sites in peer_htlcs.c and pay.c, and includes a test plugin plus a functional test demonstrating that a 1,000 msat payment can be accepted against a 10,000 msat invoice when the hook overrides the expected amount.
Changed components
lightningd/htlc_set.clightningd/htlc_set.hlightningd/pay.clightningd/peer_htlcs.cdoc/developers-guide/plugin-development/hooks.mdInspect captured patch +96 / −6
diff --git a/doc/developers-guide/plugin-development/hooks.md b/doc/developers-guide/plugin-development/hooks.md
index b8443387..9b3bacd9 100644
--- a/doc/developers-guide/plugin-development/hooks.md
+++ b/doc/developers-guide/plugin-development/hooks.md
@@ -462,6 +462,8 @@ It can also specify `forward_to` in the response, replacing the destination. Th
Also, it can specify `extra_tlvs` in the response. This will replace the TLV-stream `update_add_htlc_tlvs` in the `update_add_htlc` message for forwarded htlcs.
+If the node is the final destination, the plugin can also replace the amount of the invoice that belongs to the `payment_hash` by specifying `invoice_msat`.
+
```json
{
"result": "fail",
diff --git a/lightningd/htlc_set.c b/lightningd/htlc_set.c
index b2fbf86e..4c3d3408 100644
--- a/lightningd/htlc_set.c
+++ b/lightningd/htlc_set.c
@@ -133,6 +133,7 @@ void htlc_set_add_(struct lightningd *ld,
struct logger *log,
struct amount_msat msat,
struct amount_msat total_msat,
+ const struct amount_msat *invoice_msat_override,
const struct sha256 *payment_hash,
const struct secret *payment_secret,
void (*fail)(void *, const u8 *),
@@ -151,7 +152,8 @@ void htlc_set_add_(struct lightningd *ld,
* - Note: "amount paid" specified there is the `total_msat` field.
*/
details = invoice_check_payment(tmpctx, ld, payment_hash, total_msat,
- NULL, payment_secret, &err);
+ invoice_msat_override, payment_secret,
+ &err);
if (!details) {
log_debug(log, "payment failed: %s", err);
fail(arg, take(failmsg_incorrect_or_unknown(NULL, ld, msat)));
diff --git a/lightningd/htlc_set.h b/lightningd/htlc_set.h
index bb0119dc..d6de0329 100644
--- a/lightningd/htlc_set.h
+++ b/lightningd/htlc_set.h
@@ -60,15 +60,17 @@ void htlc_set_add_(struct lightningd *ld,
struct logger *log,
struct amount_msat msat,
struct amount_msat total_msat,
+ const struct amount_msat *invoice_msat_override,
const struct sha256 *payment_hash,
const struct secret *payment_secret,
void (*fail)(void *, const u8 *),
void (*succeeded)(void *, const struct preimage *),
void *arg);
-#define htlc_set_add(ld, log, msat, total_msat, payment_hash, payment_secret, \
- fail, succeeded, arg) \
- htlc_set_add_((ld), (log), (msat), (total_msat), (payment_hash), \
+#define htlc_set_add(ld, log, msat, total_msat, invoice_msat_override, \
+ payment_hash, payment_secret, fail, succeeded, arg)\
+ htlc_set_add_((ld), (log), (msat), (total_msat), \
+ (invoice_msat_override), (payment_hash), \
(payment_secret), \
typesafe_cb_postargs(void, void *, \
(fail), (arg), \
diff --git a/lightningd/pay.c b/lightningd/pay.c
index 10432eca..b38b8e1e 100644
--- a/lightningd/pay.c
+++ b/lightningd/pay.c
@@ -1973,7 +1973,7 @@ static struct command_result *json_injectpaymentonion(struct command *cmd,
* not resolve immediately */
fixme_ignore(command_still_pending(cmd));
htlc_set_add(cmd->ld, cmd->ld->log, *msat, *payload->total_msat,
- payment_hash, payload->payment_secret,
+ NULL, payment_hash, payload->payment_secret,
selfpay_mpp_fail, selfpay_mpp_succeeded,
selfpay);
return command_its_complicated("htlc_set_add may have immediately succeeded or failed");
diff --git a/lightningd/peer_htlcs.c b/lightningd/peer_htlcs.c
index 6dea6018..99c287f9 100644
--- a/lightningd/peer_htlcs.c
+++ b/lightningd/peer_htlcs.c
@@ -3,9 +3,11 @@
#include <ccan/mem/mem.h>
#include <ccan/tal/str/str.h>
#include <channeld/channeld_wiregen.h>
+#include <common/amount.h>
#include <common/blinding.h>
#include <common/ecdh.h>
#include <common/json_command.h>
+#include <common/json_parse.h>
#include <common/onion_decode.h>
#include <common/onionreply.h>
#include <common/timeout.h>
@@ -435,6 +437,7 @@ static void handle_localpay(struct htlc_in *hin,
struct amount_msat amt_to_forward,
u32 outgoing_cltv_value,
struct amount_msat total_msat,
+ const struct amount_msat *invoice_msat_override,
const struct secret *payment_secret,
const u8 *payment_metadata)
{
@@ -525,6 +528,7 @@ static void handle_localpay(struct htlc_in *hin,
htlc_set_add(ld, hin->key.channel->log,
hin->msat, total_msat,
+ invoice_msat_override,
&hin->payment_hash,
payment_secret,
local_fail_in_htlc,
@@ -947,6 +951,9 @@ struct htlc_accepted_hook_payload {
size_t failtlvpos;
const char *failexplanation;
u8 *extra_tlvs_raw;
+ /* Default is NULL, if NOT NULL: used to override the amount of the
+ * invoice this htlc belongs to in checks! */
+ struct amount_msat *invoice_msat;
};
static void
@@ -1003,7 +1010,8 @@ static bool htlc_accepted_hook_deserialize(struct htlc_accepted_hook_payload *re
struct htlc_in *hin = request->hin;
struct lightningd *ld = request->ld;
struct preimage payment_preimage;
- const jsmntok_t *resulttok, *paykeytok, *payloadtok, *fwdtok, *extra_tlvs_tok;
+ const jsmntok_t *resulttok, *paykeytok, *payloadtok, *fwdtok, *extra_tlvs_tok,
+ *invmsattok;
u8 *failonion, *raw_tlvs;
if (!toks || !buffer)
@@ -1018,6 +1026,17 @@ static bool htlc_accepted_hook_deserialize(struct htlc_accepted_hook_payload *re
json_strdup(tmpctx, buffer, toks));
}
+ invmsattok = json_get_member(buffer, toks, "invoice_msat");
+ if (invmsattok) {
+ tal_free(request->invoice_msat);
+ request->invoice_msat = tal(request, struct amount_msat);
+ if (!json_to_msat(buffer, invmsattok, request->invoice_msat)) {
+ fatal("Bad invoice_msat for htlc_accepted hook: %.*s",
+ invmsattok->end - invmsattok->start,
+ buffer + invmsattok->start);
+ }
+ }
+
extra_tlvs_tok = json_get_member(buffer, toks, "extra_tlvs");
if (extra_tlvs_tok) {
size_t max;
@@ -1275,6 +1294,7 @@ htlc_accepted_hook_final(struct htlc_accepted_hook_payload *request STEALS)
request->payload->amt_to_forward,
request->payload->outgoing_cltv,
*request->payload->total_msat,
+ request->invoice_msat,
request->payload->payment_secret,
request->payload->payment_metadata);
@@ -1555,6 +1575,11 @@ static bool peer_accepted_htlc(const tal_t *ctx,
hook_payload->extra_tlvs_raw = NULL;
}
+ /* We don't set the invoice amount here, if it is set during a hook
+ * response, it will be used to override the actual invoice amount on
+ * later checks. */
+ hook_payload->invoice_msat = NULL;
+
plugin_hook_call_htlc_accepted(ld, NULL, hook_payload);
/* Falling through here is ok, after all the HTLC locked */
diff --git a/tests/plugins/override_invoice_msat.py b/tests/plugins/override_invoice_msat.py
new file mode 100755
index 00000000..d3f89e89
--- /dev/null
+++ b/tests/plugins/override_invoice_msat.py
@@ -0,0 +1,29 @@
+#!/usr/bin/env python3
+"""A plugin that overrides the amount of the invoice that belongs to an HTLC."""
+
+from pyln.client import Plugin
+
+
+plugin = Plugin()
+
+
+@plugin.hook("htlc_accepted")
+def on_htlc_accepted(htlc, onion, plugin, **kwargs):
+ res = {"result": "continue"}
+ if plugin.invoice_msat:
+ res["invoice_msat"] = plugin.invoice_msat
+ return res
+
+
+@plugin.method("setinvoicemsat")
+def setinvoicemsat(plugin, msat: int):
+ """Sets invoice_msat for the htlc_accepted response."""
+ plugin.invoice_msat = msat
+
+
+@plugin.init()
+def on_init(**kwargs):
+ plugin.invoice_msat = None
+
+
+plugin.run()
diff --git a/tests/test_pay.py b/tests/test_pay.py
index 9f97ab82..3bfc2899 100644
--- a/tests/test_pay.py
+++ b/tests/test_pay.py
@@ -7133,3 +7133,32 @@ def test_htlc_tlv_crash(node_factory):
l1.rpc.waitsendpay(inv1['payment_hash'], TIMEOUT)
l1.rpc.waitsendpay(inv2['payment_hash'], TIMEOUT)
+
+
+def test_invoice_amount_override(node_factory):
+ """Uses the htlc_accepted hook response value `invoice_msat` to override
+ the expected total payment amount of the invoice.
+ """
+ plugin = os.path.join(os.path.dirname(__file__), "plugins/override_invoice_msat.py")
+ l1, l2 = node_factory.line_graph(2, opts=[{}, {"plugin": plugin}])
+
+ inv = l2.rpc.invoice(10_000, "expected_amt_override", "expected_amt_override")
+
+ route = [
+ {
+ "amount_msat": 1_000, # Reduced amount that is below the expected amount
+ "id": l2.info["id"],
+ "delay": 10,
+ "channel": first_scid(l1, l2),
+ }
+ ]
+
+ with pytest.raises(RpcError):
+ l1.rpc.sendpay(route, inv["payment_hash"], payment_secret=inv["payment_secret"])
+ l1.rpc.waitsendpay(inv["payment_hash"])
+
+ # Override expected invoice amount, via htlc_accepted.
+ l2.rpc.setinvoicemsat(msat=1_000)
+
+ l1.rpc.sendpay(route, inv["payment_hash"], payment_secret=inv["payment_secret"])
+ assert l1.rpc.waitsendpay(inv["payment_hash"])["status"] == "complete"
diff --git a/wallet/test/run-wallet.c b/wallet/test/run-wallet.c
index 3ca09e02..5ce78e71 100644
--- a/wallet/test/run-wallet.c
+++ b/wallet/test/run-wallet.c
@@ -380,6 +380,7 @@ void htlc_set_add_(struct lightningd *ld UNNEEDED,
struct logger *log UNNEEDED,
struct amount_msat msat UNNEEDED,
struct amount_msat total_msat UNNEEDED,
+ const struct amount_msat *invoice_msat_override UNNEEDED,
const struct sha256 *payment_hash UNNEEDED,
const struct secret *payment_secret UNNEEDED,
void (*fail)(void * UNNEEDED, const u8 *) UNNEEDED,
Why this scored 40/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.