What changed, and why it matters
This commit adds a new command called xkeysend to Core Lightning, which lets users send money to another node without needing an invoice. The code itself is a feature addition, not a bug fix. It includes a note that keysend payments do not provide cryptographic proof-of-payment, which is a known limitation of the keysend design. There is no direct evidence in the commit of a security vulnerability being fixed or introduced.
No immediate security action is required. Operators should review the xkeysend documentation to understand that keysend payments lack proof-of-payment, and should ensure only authorized callers can invoke the xkeysend RPC. Developers may want to audit the preapprovekeysend flow and the handling of extra_tlvs for robustness.
Security signals we found
New RPC command xkeysend added to plugins/xpay/xpay.c
Local preimage generation via randbytes() and SHA256 for keysend payments
Self-keysends explicitly rejected with JSONRPC2_INVALID_PARAMS
preapprovekeysend called before initiating payment
WIRE_INVALID_ONION_PAYLOAD handled specially for keysend (no invstring)
Documentation acknowledges lack of proof-of-payment for keysend
Evidence from the diff
The commit introduces the xkeysend JSON-RPC command in the xpay plugin. It reuses the existing xpay payment machinery but passes a NULL invstring to distinguish keysend from invoice-based payments. A payment preimage is generated locally with randbytes(), hashed to a payment_hash, and embedded in the final TLV payload (type 5482373484) so the destination can claim the payment. The code explicitly blocks self-keysends and calls preapprovekeysend before proceeding. Error handling is adjusted so WIRE_INVALID_ONION_PAYLOAD is treated as a permanent destination failure only for keysend. The documentation notes the absence of proof-of-payment, which is inherent to keysend.
Changed components
plugins/xpay/xpay.cdoc/schemas/xkeysend.jsoncontrib/msggen/msggen/schema.jsondoc/Makefiledoc/index.rstInspect captured patch +410 / −4
diff --git a/contrib/msggen/msggen/schema.json b/contrib/msggen/msggen/schema.json
index 03f31c53..e2db77cd 100644
--- a/contrib/msggen/msggen/schema.json
+++ b/contrib/msggen/msggen/schema.json
@@ -38678,6 +38678,142 @@
}
]
},
+ "xkeysend.json": {
+ "$schema": "../rpc-schema-draft.json",
+ "type": "object",
+ "rpc": "xkeysend",
+ "title": "Send funds to a node without an invoice",
+ "added": "v26.06",
+ "description": [
+ "The **xkeysend** RPC command attempts to find a route to the given destination, and send the specified amount to it. Unlike the `xpay` RPC command the `xkeysend` command does not require an invoice, instead it uses the `destination` node ID, and `amount` to find a route to the specified node.",
+ "",
+ "In order for the destination to be able to claim the payment, the `payment_key` is randomly generated by the sender and included in the encrypted payload for the destination. As a consequence there is not proof-of-payment, like there is with an invoice where the `payment_key` is generated on the destination, and the only way sender could have it is by sending a payment. Please ensure that this matches your use-case when using `xkeysend`.",
+ "",
+ "This replaces the older `keysend` command."
+ ],
+ "request": {
+ "required": [
+ "destination",
+ "amount_msat"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "destination": {
+ "type": "pubkey",
+ "description": [
+ "The 33 byte, hex-encoded, node ID of the node that the payment should go to."
+ ]
+ },
+ "amount_msat": {
+ "type": "msat",
+ "description": [
+ "A whole number, or a whole number with suffix `msat` or `sat`, or a three decimal point number with suffix `sat`, or an 1 to 11 decimal point number suffixed by `btc`."
+ ]
+ },
+ "label": {
+ "type": "string",
+ "description": [
+ "Attach a label to the payment for which is returned in `listpays` and `listsendpays`. This is for your own use: it is not visible to the recipient."
+ ]
+ },
+ "maxfee": {
+ "type": "msat",
+ "description": [
+ "*maxfee* creates an absolute limit on what fee we will pay."
+ ]
+ },
+ "layers": {
+ "type": "array",
+ "description": [
+ "These are askrene layers to apply: these can alter the topology or provide additional information on the lightning network. See askrene-create-layer."
+ ],
+ "items": {
+ "type": "string",
+ "description": [
+ "name of an existing layer"
+ ]
+ }
+ },
+ "retry_for": {
+ "type": "u32",
+ "description": [
+ "Until *retry_for* seconds passes, the command will keep finding routes and retrying the payment. However, a payment may be delayed for up to `maxdelay` blocks by another node; clients should be prepared for this worst case."
+ ],
+ "default": "60 seconds"
+ },
+ "maxdelay": {
+ "type": "u32",
+ "description": [
+ "Number of blocks the payment may be delayed."
+ ]
+ },
+ "extratlvs": {
+ "type": "object",
+ "additionalProperties": true,
+ "required": [],
+ "description": [
+ "Dictionary of additional fields to insert into the final tlv. The format is 'fieldnumber': 'hexstring'."
+ ]
+ }
+ }
+ },
+ "response": {
+ "required": [
+ "payment_preimage",
+ "failed_parts",
+ "successful_parts",
+ "amount_msat",
+ "amount_sent_msat"
+ ],
+ "properties": {
+ "payment_preimage": {
+ "type": "secret",
+ "description": [
+ "The proof of payment: SHA256 of this **payment_hash**."
+ ]
+ },
+ "failed_parts": {
+ "type": "u64",
+ "description": [
+ "How many separate payment parts failed."
+ ]
+ },
+ "successful_parts": {
+ "type": "u64",
+ "description": [
+ "How many separate payment parts succeeded (or are anticipated to succeed). This will be at least one."
+ ]
+ },
+ "amount_msat": {
+ "type": "msat",
+ "description": [
+ "Amount the recipient received."
+ ]
+ },
+ "amount_sent_msat": {
+ "type": "msat",
+ "description": [
+ "Total amount we sent (including fees)."
+ ]
+ }
+ },
+ "post_return_value_notes": [
+ "Note that the return is the same as it is for `xpay`."
+ ]
+ },
+ "author": [
+ "Rusty Russell [rusty@rustcorp.com.au](mailto:rusty@rustcorp.com.au) is mainly responsible."
+ ],
+ "see_also": [
+ "lightning-listpays(7)",
+ "lightning-askrene-create-layer(7)",
+ "lightning-askrene-create-channel(7)",
+ "lightning-askrene-update-channel(7)"
+ ],
+ "resources": [
+ "Main web site: [https://github.com/ElementsProject/lightning](https://github.com/ElementsProject/lightning)"
+ ]
+ },
"xpay.json": {
"$schema": "../rpc-schema-draft.json",
"type": "object",
diff --git a/doc/Makefile b/doc/Makefile
index 81e3fc47..39e6929d 100644
--- a/doc/Makefile
+++ b/doc/Makefile
@@ -159,6 +159,7 @@ MARKDOWNPAGES := doc/addgossip.7 \
doc/wait.7 \
doc/waitsendpay.7 \
doc/withdraw.7 \
+ doc/xkeysend.7 \
doc/xpay.7
NOTIFICATION_SCHEMAS := $(wildcard doc/schemas/notification/*.json)
diff --git a/doc/index.rst b/doc/index.rst
index 96c3443e..26d38b63 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -215,5 +215,6 @@ Core Lightning Documentation
waitinvoice <waitinvoice.7.md>
waitsendpay <waitsendpay.7.md>
withdraw <withdraw.7.md>
+ xkeysend <xkeysend.7.md>
xpay <xpay.7.md>
.. block_end manpages
diff --git a/doc/schemas/xkeysend.json b/doc/schemas/xkeysend.json
new file mode 100644
index 00000000..e45983ba
--- /dev/null
+++ b/doc/schemas/xkeysend.json
@@ -0,0 +1,136 @@
+{
+ "$schema": "../rpc-schema-draft.json",
+ "type": "object",
+ "rpc": "xkeysend",
+ "title": "Send funds to a node without an invoice",
+ "added": "v26.06",
+ "description": [
+ "The **xkeysend** RPC command attempts to find a route to the given destination, and send the specified amount to it. Unlike the `xpay` RPC command the `xkeysend` command does not require an invoice, instead it uses the `destination` node ID, and `amount` to find a route to the specified node.",
+ "",
+ "In order for the destination to be able to claim the payment, the `payment_key` is randomly generated by the sender and included in the encrypted payload for the destination. As a consequence there is not proof-of-payment, like there is with an invoice where the `payment_key` is generated on the destination, and the only way sender could have it is by sending a payment. Please ensure that this matches your use-case when using `xkeysend`.",
+ "",
+ "This replaces the older `keysend` command."
+ ],
+ "request": {
+ "required": [
+ "destination",
+ "amount_msat"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "destination": {
+ "type": "pubkey",
+ "description": [
+ "The 33 byte, hex-encoded, node ID of the node that the payment should go to."
+ ]
+ },
+ "amount_msat": {
+ "type": "msat",
+ "description": [
+ "A whole number, or a whole number with suffix `msat` or `sat`, or a three decimal point number with suffix `sat`, or an 1 to 11 decimal point number suffixed by `btc`."
+ ]
+ },
+ "label": {
+ "type": "string",
+ "description": [
+ "Attach a label to the payment for which is returned in `listpays` and `listsendpays`. This is for your own use: it is not visible to the recipient."
+ ]
+ },
+ "maxfee": {
+ "type": "msat",
+ "description": [
+ "*maxfee* creates an absolute limit on what fee we will pay."
+ ]
+ },
+ "layers": {
+ "type": "array",
+ "description": [
+ "These are askrene layers to apply: these can alter the topology or provide additional information on the lightning network. See askrene-create-layer."
+ ],
+ "items": {
+ "type": "string",
+ "description": [
+ "name of an existing layer"
+ ]
+ }
+ },
+ "retry_for": {
+ "type": "u32",
+ "description": [
+ "Until *retry_for* seconds passes, the command will keep finding routes and retrying the payment. However, a payment may be delayed for up to `maxdelay` blocks by another node; clients should be prepared for this worst case."
+ ],
+ "default": "60 seconds"
+ },
+ "maxdelay": {
+ "type": "u32",
+ "description": [
+ "Number of blocks the payment may be delayed."
+ ]
+ },
+ "extratlvs": {
+ "type": "object",
+ "additionalProperties": true,
+ "required": [],
+ "description": [
+ "Dictionary of additional fields to insert into the final tlv. The format is 'fieldnumber': 'hexstring'."
+ ]
+ }
+ }
+ },
+ "response": {
+ "required": [
+ "payment_preimage",
+ "failed_parts",
+ "successful_parts",
+ "amount_msat",
+ "amount_sent_msat"
+ ],
+ "properties": {
+ "payment_preimage": {
+ "type": "secret",
+ "description": [
+ "The proof of payment: SHA256 of this **payment_hash**."
+ ]
+ },
+ "failed_parts": {
+ "type": "u64",
+ "description": [
+ "How many separate payment parts failed."
+ ]
+ },
+ "successful_parts": {
+ "type": "u64",
+ "description": [
+ "How many separate payment parts succeeded (or are anticipated to succeed). This will be at least one."
+ ]
+ },
+ "amount_msat": {
+ "type": "msat",
+ "description": [
+ "Amount the recipient received."
+ ]
+ },
+ "amount_sent_msat": {
+ "type": "msat",
+ "description": [
+ "Total amount we sent (including fees)."
+ ]
+ }
+ },
+ "post_return_value_notes": [
+ "Note that the return is the same as it is for `xpay`."
+ ]
+ },
+ "author": [
+ "Rusty Russell [rusty@rustcorp.com.au](mailto:rusty@rustcorp.com.au) is mainly responsible."
+ ],
+ "see_also": [
+ "lightning-listpays(7)",
+ "lightning-askrene-create-layer(7)",
+ "lightning-askrene-create-channel(7)",
+ "lightning-askrene-update-channel(7)"
+ ],
+ "resources": [
+ "Main web site: [https://github.com/ElementsProject/lightning](https://github.com/ElementsProject/lightning)"
+ ]
+}
diff --git a/plugins/xpay/xpay.c b/plugins/xpay/xpay.c
index aa7b4efd..e6bda299 100644
--- a/plugins/xpay/xpay.c
+++ b/plugins/xpay/xpay.c
@@ -19,6 +19,7 @@
#include <common/onion_encode.h>
#include <common/onionreply.h>
#include <common/pseudorand.h>
+#include <common/randbytes.h>
#include <common/route.h>
#include <common/wireaddr.h>
#include <errno.h>
@@ -26,6 +27,8 @@
#include <plugins/libplugin.h>
#include <stdarg.h>
+#define PREIMAGE_TLV_TYPE 5482373484
+
/* For the whole plugin */
struct xpay {
struct pubkey local_id;
@@ -69,7 +72,7 @@ struct payment {
struct command *cmd;
/* Unique id */
u64 unique_id;
- /* For logging, and for sendpays */
+ /* For logging, and for sendpays: NULL for xkeysend! */
const char *invstring;
/* Explicit layers they told us to include */
const char **layers;
@@ -978,6 +981,16 @@ static void update_knowledge_from_error(struct command *aux_cmd,
*/
if (from_final) {
switch (failcode) {
+ /* This is possible if we're keysending */
+ case WIRE_INVALID_ONION_PAYLOAD:
+ if (!attempt->payment->invstring) {
+ payment_give_up(aux_cmd, attempt->payment,
+ PAY_DESTINATION_PERM_FAIL,
+ "Destination reported %s (likely doesn't support keysend)",
+ errmsg);
+ return;
+ }
+ /* Fall thru */
/* These two are deprecated */
case WIRE_FINAL_INCORRECT_CLTV_EXPIRY:
case WIRE_FINAL_INCORRECT_HTLC_AMOUNT:
@@ -986,7 +999,6 @@ static void update_knowledge_from_error(struct command *aux_cmd,
case WIRE_INVALID_ONION_VERSION:
case WIRE_INVALID_ONION_HMAC:
case WIRE_INVALID_ONION_KEY:
- case WIRE_INVALID_ONION_PAYLOAD:
/* These should not be sent by final node */
case WIRE_TEMPORARY_CHANNEL_FAILURE:
@@ -1412,7 +1424,11 @@ static struct command_result *do_inject(struct command *aux_cmd,
json_add_u32(req->js, "cltv_expiry", initial_cltv_delta(attempt) + effective_bheight);
json_add_u64(req->js, "partid", attempt->partid);
json_add_u64(req->js, "groupid", attempt->payment->group_id);
- json_add_string(req->js, "invstring", attempt->payment->invstring);
+ /* Use invstring for payments, destination directly for keysend */
+ if (attempt->payment->invstring)
+ json_add_string(req->js, "invstring", attempt->payment->invstring);
+ else
+ json_add_pubkey(req->js, "destination", &attempt->payment->destination);
json_add_amount_msat(req->js, "destination_msat", attempt->delivers);
if (attempt->payment->localinvreqid)
json_add_sha256(req->js, "localinvreqid", attempt->payment->localinvreqid);
@@ -2324,7 +2340,7 @@ static struct payment *new_payment(const tal_t *ctx,
payment->deadline = timemono_add(time_mono(), time_from_sec(retryfor));
payment->start_blockheight = xpay->blockheight;
payment->cmd = cmd;
- payment->invstring = tal_strdup(payment, invstring);
+ payment->invstring = tal_strdup_or_null(payment, invstring);
payment->localinvreqid = tal_dup_or_null(payment, struct sha256, localinvreqid);
if (label)
payment->label = json_escape_dup(payment, label);
@@ -2687,6 +2703,118 @@ static struct command_result *xpay_layer_created(struct command *aux_cmd,
return aux_command_done(aux_cmd);
}
+static struct command_result *
+preapprovekeysend_succeed(struct command *cmd,
+ const char *method,
+ const char *buf,
+ const jsmntok_t *result,
+ struct payment *payment)
+{
+ /* Now we can conclude `check` command */
+ if (command_check_only(cmd)) {
+ return command_check_done(cmd);
+ }
+
+ /* Actually we don't need a private layer, but unification is easy. */
+ return populate_private_layer(cmd, payment);
+}
+
+static struct command_result *json_xkeysend(struct command *cmd,
+ const char *buf,
+ const jsmntok_t *params)
+{
+ struct xpay *xpay = xpay_of(cmd->plugin);
+ struct amount_msat *msat, *maxfee;
+ struct pubkey *dst;
+ u32 *maxdelay;
+ unsigned int *retryfor;
+ struct payment *payment;
+ struct json_escape *label;
+ const char *err;
+ struct preimage preimage;
+ struct sha256 payment_hash;
+ const char **layers;
+ struct tlv_field *extra_fields;
+ u8 *tlvs;
+ struct out_req *req;
+
+ if (!param_check(cmd, buf, params,
+ p_req("destination", param_pubkey, &dst),
+ p_req("amount_msat", param_msat, &msat),
+ p_opt("label", param_label, &label),
+ p_opt("maxfee", param_msat, &maxfee),
+ p_opt("layers", param_string_array, &layers),
+ p_opt_def("retry_for", param_number, &retryfor, 60),
+ p_opt_def("maxdelay", param_number, &maxdelay, 2016),
+ p_opt("extratlvs", param_extra_tlvs, &extra_fields),
+ NULL))
+ return command_param_failed();
+
+ randbytes(&preimage, sizeof(preimage));
+ sha256(&payment_hash, &preimage, sizeof(preimage));
+
+ /* We explicitly prohibit self-keysends */
+ if (pubkey_eq(&xpay->local_id, dst)) {
+ return command_fail(cmd, JSONRPC2_INVALID_PARAMS,
+ "We are the destination. Keysend cannot be used to send funds to yourself");
+ }
+
+ payment = new_payment(cmd, cmd,
+ *retryfor,
+ *maxdelay,
+ layers,
+ NULL, /* NULL invstring is the marker of a keysend vs pay */
+ dst,
+ &payment_hash,
+ *msat,
+ NULL,
+ maxfee,
+ NULL, NULL,
+ // 22 is the Rust-Lightning default and the
+ // highest minimum CLTV we know of.
+ 22,
+ label,
+ NULL,
+ false,
+ &err);
+ if (!payment)
+ return command_fail(cmd, JSONRPC2_INVALID_PARAMS,
+ "%s", err);
+
+ if (!extra_fields)
+ extra_fields = tal_arr(cmd, struct tlv_field, 0);
+ tlvstream_set_raw(&extra_fields, PREIMAGE_TLV_TYPE,
+ &preimage, sizeof(struct preimage));
+
+ /* Keysend only supports a single part, usually (we support multi!) */
+ payment->maxparts = 1;
+
+ /* Single payments should always use shadow routes */
+ payment->use_shadow = true;
+
+ /* Convert tlvs into their array representation for appending (assumes
+ * they're greater than any TLV we set!) */
+ tlvs = tal_arr(payment, u8, 0);
+ towire_tlvstream_raw(&tlvs, extra_fields);
+ payment->extra_tlvs = tlvs;
+
+ /* We do pre-approval immediately (note: even if command_check_only!) */
+ if (command_check_only(cmd)) {
+ req = jsonrpc_request_start(cmd, "check",
+ preapprovekeysend_succeed,
+ forward_error, payment);
+ json_add_string(req->js, "command_to_check", "preapprovekeysend");
+ } else {
+ req = jsonrpc_request_start(cmd, "preapprovekeysend",
+ preapprovekeysend_succeed,
+ forward_error, payment);
+ }
+ json_add_pubkey(req->js, "destination", &payment->destination);
+ json_add_sha256(req->js, "payment_hash", &payment->payment_hash);
+ json_add_amount_msat(req->js, "amount_msat", payment->amount);
+ return send_outreq(req);
+}
+
static const char *init(struct command *init_cmd,
const char *buf UNUSED, const jsmntok_t *config UNUSED)
{
@@ -2739,6 +2867,10 @@ static const struct plugin_command commands[] = {
"xpay-as-pay",
json_xpay_as_pay,
},
+ {
+ "xkeysend",
+ json_xkeysend,
+ },
};
static struct command_result *handle_block_added(struct command *cmd,
Why this scored 24/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.