xpay: support paying a (simple) bolt12 offer directly.
What changed, and why it matters
This commit adds a convenience feature to the Core Lightning `xpay` plugin so users can pay a simple BOLT12 offer directly, without first running `fetchinvoice` manually. The plugin now detects an offer string, validates it, calls `fetchinvoice` internally, and then proceeds with normal payment. There is no security-relevant change visible in the diff.
No security action required; this is a feature addition. Normal code review and regression testing are sufficient.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The change exposes bolt12_has_offer_prefix, refactors json_xpay_core into json_xpay_params plus xpay_core, and adds an offer-handling path that decodes the offer, rejects unsupported cases (alternate currency, recurring offers, mismatched/excess amounts), optionally checks the command, then issues a fetchinvoice JSON-RPC request before continuing with the existing payment flow. maxdelay and maxparts are changed from pointers to values in the struct payment. Tests cover amount validation and successful offer payments.
Changed components
plugins/xpay/xpay.ccommon/bolt12.ccommon/bolt12.hcontrib/msggen/msggen/schema.jsondoc/schemas/xpay.jsontests/test_xpay.pyInspect captured patch +159 / −23
diff --git a/common/bolt12.c b/common/bolt12.c
index a0c8c9f0..291b4ccb 100644
--- a/common/bolt12.c
+++ b/common/bolt12.c
@@ -594,7 +594,7 @@ static bool bolt12_has_request_prefix(const char *str)
return strstarts(str, "lnr1") || strstarts(str, "LNR1");
}
-static bool bolt12_has_offer_prefix(const char *str)
+bool bolt12_has_offer_prefix(const char *str)
{
return strstarts(str, "lno1") || strstarts(str, "LNO1");
}
diff --git a/common/bolt12.h b/common/bolt12.h
index 0ad35464..29effd55 100644
--- a/common/bolt12.h
+++ b/common/bolt12.h
@@ -125,6 +125,7 @@ void offer_period_paywindow(const struct recurrence *recurrence,
/**
* Preliminary prefix check to see if the string might be a bolt12 string.
*/
+bool bolt12_has_offer_prefix(const char *str);
bool bolt12_has_prefix(const char *str);
/**
diff --git a/contrib/msggen/msggen/schema.json b/contrib/msggen/msggen/schema.json
index 47140c4f..6844c033 100644
--- a/contrib/msggen/msggen/schema.json
+++ b/contrib/msggen/msggen/schema.json
@@ -35529,7 +35529,7 @@
"invstring": {
"type": "string",
"description": [
- "bolt11 or bolt12 invoice"
+ "bolt11 or bolt12 invoice, or a bolt12 (non-recursive) offer. If it's an offer, the invoice is fetch using `fetchinvoice` automatically."
]
},
"amount_msat": {
diff --git a/doc/schemas/xpay.json b/doc/schemas/xpay.json
index b962ade2..414e9a2c 100644
--- a/doc/schemas/xpay.json
+++ b/doc/schemas/xpay.json
@@ -17,7 +17,7 @@
"invstring": {
"type": "string",
"description": [
- "bolt11 or bolt12 invoice"
+ "bolt11 or bolt12 invoice, or a bolt12 (non-recursive) offer. If it's an offer, the invoice is fetch using `fetchinvoice` automatically."
]
},
"amount_msat": {
diff --git a/plugins/xpay/xpay.c b/plugins/xpay/xpay.c
index ec911652..589229bc 100644
--- a/plugins/xpay/xpay.c
+++ b/plugins/xpay/xpay.c
@@ -77,9 +77,9 @@ struct payment {
/* Maximum fee we're prepare to pay */
struct amount_msat maxfee;
/* Maximum delay on the route we're ok with */
- u32 *maxdelay;
+ u32 maxdelay;
/* Maximum number of payment routes that can be pending. */
- u32 *maxparts;
+ u32 maxparts;
/* Do we have to do it all in a single part? */
bool disable_mpp;
/* BOLT11 payment secret (NULL for BOLT12, it uses blinded paths) */
@@ -163,6 +163,18 @@ struct attempt {
const struct preimage *preimage;
};
+/* Recursion */
+static struct command_result *xpay_core(struct command *cmd,
+ const char *invstring TAKES,
+ const struct amount_msat *msat,
+ const struct amount_msat *maxfee,
+ const char **layers,
+ u32 retryfor,
+ const struct amount_msat *partial,
+ u32 maxdelay,
+ u32 dev_maxparts,
+ bool as_pay);
+
/* Wrapper for pending commands (ignores return) */
static void was_pending(const struct command_result *res)
{
@@ -1367,10 +1379,10 @@ static struct command_result *getroutes_for(struct command *aux_cmd,
json_array_end(req->js);
json_add_amount_msat(req->js, "maxfee_msat", maxfee);
json_add_u32(req->js, "final_cltv", payment->final_cltv);
- json_add_u32(req->js, "maxdelay", *payment->maxdelay);
+ json_add_u32(req->js, "maxdelay", payment->maxdelay);
count_pending = count_current_attempts(payment);
- assert(*payment->maxparts > count_pending);
- json_add_u32(req->js, "maxparts", *payment->maxparts - count_pending);
+ assert(payment->maxparts > count_pending);
+ json_add_u32(req->js, "maxparts", payment->maxparts - count_pending);
return send_payment_req(aux_cmd, payment, req);
}
@@ -1640,34 +1652,132 @@ preapproveinvoice_succeed(struct command *cmd,
return populate_private_layer(cmd, payment);
}
-static struct command_result *json_xpay_core(struct command *cmd,
- const char *buffer,
- const jsmntok_t *params,
- bool as_pay)
+struct xpay_params {
+ struct amount_msat *maxfee, *partial;
+ const char **layers;
+ unsigned int retryfor;
+ u32 maxdelay, dev_maxparts;
+};
+
+static struct command_result *
+invoice_fetched(struct command *cmd,
+ const char *method,
+ const char *buf,
+ const jsmntok_t *result,
+ struct xpay_params *params)
+{
+ char *inv;
+
+ inv = json_strdup(NULL, buf, json_get_member(buf, result, "invoice"));
+ return xpay_core(cmd, take(to_canonical_invstr(NULL, take(inv))),
+ NULL, params->maxfee, params->layers,
+ params->retryfor, params->partial, params->maxdelay,
+ params->dev_maxparts, false);
+}
+
+static struct command_result *json_xpay_params(struct command *cmd,
+ const char *buffer,
+ const jsmntok_t *params,
+ bool as_pay)
{
- struct xpay *xpay = xpay_of(cmd->plugin);
struct amount_msat *msat, *maxfee, *partial;
- struct payment *payment = tal(cmd, struct payment);
+ const char *invstring;
+ const char **layers;
+ u32 *maxdelay, *maxparts;
unsigned int *retryfor;
struct out_req *req;
- u64 now, invexpiry;
char *err;
if (!param_check(cmd, buffer, params,
- p_req("invstring", param_invstring, &payment->invstring),
+ p_req("invstring", param_invstring, &invstring),
p_opt("amount_msat", param_msat, &msat),
p_opt("maxfee", param_msat, &maxfee),
- p_opt("layers", param_string_array, &payment->layers),
+ p_opt("layers", param_string_array, &layers),
p_opt_def("retry_for", param_number, &retryfor, 60),
p_opt("partial_msat", param_msat, &partial),
- p_opt_def("maxdelay", param_u32, &payment->maxdelay, 2016),
- p_opt_dev("dev_maxparts", param_u32, &payment->maxparts, 100),
+ p_opt_def("maxdelay", param_u32, &maxdelay, 2016),
+ p_opt_dev("dev_maxparts", param_u32, &maxparts, 100),
NULL))
return command_param_failed();
- if (*payment->maxparts == 0)
+
+ if (*maxparts == 0)
return command_fail(cmd, JSONRPC2_INVALID_PARAMS,
"maxparts cannot be zero");
+ /* Is this a one-shot vibe payment? Kids these days! */
+ if (!as_pay && bolt12_has_offer_prefix(invstring)) {
+ struct xpay_params *xparams;
+ struct tlv_offer *b12offer = offer_decode(tmpctx,
+ invstring,
+ strlen(invstring),
+ plugin_feature_set(cmd->plugin),
+ chainparams, &err);
+ if (!b12offer)
+ return command_fail(cmd, JSONRPC2_INVALID_PARAMS,
+ "Invalid bolt12 offer: %s", err);
+ /* We will only one-shot if we know amount! (FIXME: Convert!) */
+ if (b12offer->offer_currency)
+ return command_fail(cmd, JSONRPC2_INVALID_PARAMS,
+ "Cannot pay offer in different currency %s",
+ b12offer->offer_currency);
+ if (b12offer->offer_amount) {
+ if (msat && !amount_msat_eq(amount_msat(*b12offer->offer_amount), *msat)) {
+ return command_fail(cmd, JSONRPC2_INVALID_PARAMS,
+ "Offer amount is %s, you tried to pay %s",
+ fmt_amount_msat(tmpctx, amount_msat(*b12offer->offer_amount)),
+ fmt_amount_msat(tmpctx, *msat));
+ }
+ } else {
+ if (!msat)
+ return command_fail(cmd, JSONRPC2_INVALID_PARAMS,
+ "Must specify amount for this offer");
+ }
+ if (b12offer->offer_recurrence)
+ return command_fail(cmd, JSONRPC2_INVALID_PARAMS,
+ "Cannot xpay recurring offers");
+
+ if (command_check_only(cmd))
+ return command_check_done(cmd);
+
+ xparams = tal(cmd, struct xpay_params);
+ xparams->maxfee = maxfee;
+ xparams->partial = partial;
+ xparams->layers = layers;
+ xparams->retryfor = *retryfor;
+ xparams->maxdelay = *maxdelay;
+ xparams->dev_maxparts = *maxparts;
+
+ req = jsonrpc_request_start(cmd, "fetchinvoice",
+ invoice_fetched,
+ forward_error, xparams);
+ json_add_string(req->js, "offer", invstring);
+ if (msat)
+ json_add_amount_msat(req->js, "amount_msat", *msat);
+ return send_outreq(req);
+ }
+
+ return xpay_core(cmd, invstring,
+ msat, maxfee, layers, *retryfor, partial, *maxdelay, *maxparts,
+ as_pay);
+}
+
+static struct command_result *xpay_core(struct command *cmd,
+ const char *invstring TAKES,
+ const struct amount_msat *msat,
+ const struct amount_msat *maxfee,
+ const char **layers,
+ u32 retryfor,
+ const struct amount_msat *partial,
+ u32 maxdelay,
+ u32 dev_maxparts,
+ bool as_pay)
+{
+ struct payment *payment = tal(cmd, struct payment);
+ struct xpay *xpay = xpay_of(cmd->plugin);
+ u64 now, invexpiry;
+ struct out_req *req;
+ char *err;
+
list_head_init(&payment->current_attempts);
list_head_init(&payment->past_attempts);
payment->plugin = cmd->plugin;
@@ -1677,9 +1787,16 @@ static struct command_result *json_xpay_core(struct command *cmd,
payment->total_num_attempts = payment->num_failures = 0;
payment->requests = tal_arr(payment, struct out_req *, 0);
payment->prior_results = tal_strdup(payment, "");
- payment->deadline = timemono_add(time_mono(), time_from_sec(*retryfor));
+ payment->deadline = timemono_add(time_mono(), time_from_sec(retryfor));
payment->start_time = time_now();
payment->pay_compat = as_pay;
+ payment->invstring = tal_strdup(payment, invstring);
+ if (layers)
+ payment->layers = tal_dup_talarr(payment, const char *, layers);
+ else
+ payment->layers = NULL;
+ payment->maxdelay = maxdelay;
+ payment->maxparts = dev_maxparts;
if (bolt12_has_prefix(payment->invstring)) {
struct gossmap *gossmap = get_gossmap(xpay);
@@ -1827,14 +1944,14 @@ static struct command_result *json_xpay(struct command *cmd,
const char *buffer,
const jsmntok_t *params)
{
- return json_xpay_core(cmd, buffer, params, false);
+ return json_xpay_params(cmd, buffer, params, false);
}
static struct command_result *json_xpay_as_pay(struct command *cmd,
const char *buffer,
const jsmntok_t *params)
{
- return json_xpay_core(cmd, buffer, params, true);
+ return json_xpay_params(cmd, buffer, params, true);
}
static struct command_result *getchaininfo_done(struct command *aux_cmd,
diff --git a/tests/test_xpay.py b/tests/test_xpay.py
index 8539fdd2..822a26ff 100644
--- a/tests/test_xpay.py
+++ b/tests/test_xpay.py
@@ -978,3 +978,21 @@ def test_attempt_notifications(node_factory):
'error_code': 4103,
'error_message': 'temporary_channel_failure'}}
assert data == expected
+
+
+def test_xpay_offer(node_factory):
+ l1, l2, l3 = node_factory.line_graph(3, wait_for_announce=True)
+
+ offer1 = l3.rpc.offer('any')['bolt12']
+ offer2 = l3.rpc.offer('5sat', "5sat donation")['bolt12']
+
+ with pytest.raises(RpcError, match=r"Must specify amount for this offer"):
+ l1.rpc.xpay(offer1)
+
+ l1.rpc.xpay(offer1, 100)
+
+ with pytest.raises(RpcError, match=r"Offer amount is 5000msat, you tried to pay 1000msat"):
+ l1.rpc.xpay(offer2, 1000)
+
+ l1.rpc.xpay(offer2)
+ l1.rpc.xpay(offer2, 5000)
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.