xpay: support paying a (simple) bolt12 offer directly.

fetchinvoice is still good for detailed diagnostics and handling
recurring invoices and alternate currencies, but this covers the
"throw some sats" case well.

Changelog-Added: JSON-RPC: `xpay` can now pay a simple offer directly, rather than requiring fetchinvoice first.
Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
This commit is contained in:
Rusty Russell
2025-08-18 11:00:52 +09:30
parent aea7358976
commit 103a49ef51
6 changed files with 159 additions and 23 deletions

View File

@@ -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");
}

View File

@@ -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);
/**

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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,

View File

@@ -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)