From 3eada358e96a08b70c3d4caa250efe3eedae0997 Mon Sep 17 00:00:00 2001 From: Rusty Russell Date: Wed, 16 Dec 2020 13:48:00 +1030 Subject: [PATCH] plugins/fetchinvoice: plugin to send an invoice_request for a given offer Doesn't catch the reply yet, but prepares the invoice request based on the offer and sends it. Signed-off-by: Rusty Russell --- common/jsonrpc_errors.h | 2 + plugins/Makefile | 9 +- plugins/fetchinvoice.c | 478 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 488 insertions(+), 1 deletion(-) create mode 100644 plugins/fetchinvoice.c diff --git a/common/jsonrpc_errors.h b/common/jsonrpc_errors.h index c87f2630b..753082774 100644 --- a/common/jsonrpc_errors.h +++ b/common/jsonrpc_errors.h @@ -80,5 +80,7 @@ static const errcode_t HSM_ECDH_FAILED = 800; /* Errors from `offer` commands */ static const errcode_t OFFER_ALREADY_EXISTS = 1000; static const errcode_t OFFER_ALREADY_DISABLED = 1001; +static const errcode_t OFFER_EXPIRED = 1002; +static const errcode_t OFFER_ROUTE_NOT_FOUND = 1003; #endif /* LIGHTNING_COMMON_JSONRPC_ERRORS_H */ diff --git a/plugins/Makefile b/plugins/Makefile index 3c23564e8..59c389966 100644 --- a/plugins/Makefile +++ b/plugins/Makefile @@ -29,6 +29,10 @@ PLUGIN_OFFERS_SRC := plugins/offers.c plugins/offers_offer.c plugins/offers_invr PLUGIN_OFFERS_OBJS := $(PLUGIN_OFFERS_SRC:.c=.o) PLUGIN_OFFERS_HEADER := plugins/offers_offer.h plugins/offers_invreq_hook.h +PLUGIN_FETCHINVOICE_SRC := plugins/fetchinvoice.c +PLUGIN_FETCHINVOICE_OBJS := $(PLUGIN_FETCHINVOICE_SRC:.c=.o) +PLUGIN_FETCHINVOICE_HEADER := + PLUGIN_SPENDER_SRC := \ plugins/spender/fundchannel.c \ plugins/spender/main.c \ @@ -55,6 +59,7 @@ PLUGIN_ALL_SRC := \ ifeq ($(EXPERIMENTAL_FEATURES),1) PLUGIN_ALL_SRC += $(PLUGIN_OFFERS_SRC) +PLUGIN_ALL_SRC += $(PLUGIN_FETCHINVOICE_SRC) endif PLUGIN_ALL_HEADER := \ @@ -73,7 +78,7 @@ PLUGINS := \ plugins/spenderp ifeq ($(EXPERIMENTAL_FEATURES),1) -PLUGINS += plugins/offers +PLUGINS += plugins/offers plugins/fetchinvoice endif # Make sure these depend on everything. @@ -138,6 +143,8 @@ plugins/spenderp: bitcoin/chainparams.o bitcoin/psbt.o common/psbt_open.o $(PLUG plugins/offers: bitcoin/chainparams.o $(PLUGIN_OFFERS_OBJS) $(PLUGIN_LIB_OBJS) $(PLUGIN_COMMON_OBJS) common/bolt12.o common/bolt12_merkle.o common/iso4217.o wire/bolt12_exp_wiregen.o $(WIRE_OBJS) bitcoin/block.o common/channel_id.o bitcoin/preimage.o $(JSMN_OBJS) $(CCAN_OBJS) +plugins/fetchinvoice: bitcoin/chainparams.o $(PLUGIN_FETCHINVOICE_OBJS) $(PLUGIN_LIB_OBJS) $(PLUGIN_COMMON_OBJS) common/bolt12.o common/bolt12_merkle.o common/iso4217.o wire/bolt12_exp_wiregen.o $(WIRE_OBJS) bitcoin/block.o common/channel_id.o bitcoin/preimage.o $(JSMN_OBJS) $(CCAN_OBJS) common/gossmap.o common/dijkstra.o common/route.o common/blindedpath.o common/hmac.o common/blinding.o + $(PLUGIN_ALL_OBJS): $(PLUGIN_LIB_HEADER) # Generated from PLUGINS definition in plugins/Makefile diff --git a/plugins/fetchinvoice.c b/plugins/fetchinvoice.c new file mode 100644 index 000000000..fd2ac8b57 --- /dev/null +++ b/plugins/fetchinvoice.c @@ -0,0 +1,478 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static struct gossmap *global_gossmap; +static struct node_id local_id; + +struct sent { + /* The offer we are trying to get an invoice for. */ + struct tlv_offer *offer; + /* The invreq we sent. */ + struct tlv_invoice_request *invreq; +}; + +static struct command_result *sendonionmsg_done(struct command *cmd, + const char *buf UNUSED, + const jsmntok_t *result UNUSED, + struct sent *sent) +{ + /* FIXME: Now wait for reply. */ + return command_still_pending(cmd); +} + +static void init_gossmap(struct plugin *plugin) +{ + global_gossmap + = notleak_with_children(gossmap_load(NULL, + GOSSIP_STORE_FILENAME)); + if (!global_gossmap) + plugin_err(plugin, "Could not load gossmap %s: %s", + GOSSIP_STORE_FILENAME, strerror(errno)); +} + +static struct gossmap *get_gossmap(struct plugin *plugin) +{ + if (!global_gossmap) + init_gossmap(plugin); + else + gossmap_refresh(global_gossmap); + return global_gossmap; +} + +static struct command_result *param_offer(struct command *cmd, + const char *name, + const char *buffer, + const jsmntok_t *tok, + struct tlv_offer **offer) +{ + char *fail; + + /* BOLT-offers #12: + * - if `features` contains unknown _odd_ bits that are non-zero: + * - MUST ignore the bit. + * - if `features` contains unknown _even_ bits that are non-zero: + * - MUST NOT respond to the offer. + * - SHOULD indicate the unknown bit to the user. + */ + /* BOLT-offers #12: + * - MUST NOT set or imply any `chain_hash` not set or implied by + * the offer. + */ + *offer = offer_decode(cmd, buffer + tok->start, tok->end - tok->start, + plugin_feature_set(cmd->plugin), chainparams, + &fail); + if (!*offer) + return command_fail_badparam(cmd, name, buffer, tok, + tal_fmt(cmd, + "Unparsable offer: %s", + fail)); + + /* BOLT-offers #12: + * + * - if `node_id`, `description` or `signature` is not set: + * - MUST NOT respond to the offer. + */ + /* Note: offer_decode checks `signature` */ + if (!(*offer)->node_id) + return command_fail_badparam(cmd, name, buffer, tok, + "Offer does not contain a node_id"); + + if (!(*offer)->description) + return command_fail_badparam(cmd, name, buffer, tok, + "Offer does not contain a description"); + return NULL; +} + +static bool can_carry_onionmsg(const struct gossmap *map, + const struct gossmap_chan *c, + int dir, + struct amount_msat amount UNUSED, + void *arg UNUSED) +{ + const struct gossmap_node *n; + /* Don't use it if either side says it's disabled */ + if (!c->half[dir].enabled || !c->half[!dir].enabled) + return false; + + /* Check features of recipient */ + n = gossmap_nth_node(map, c, !dir); + return n && gossmap_node_get_feature(map, n, OPT_ONION_MESSAGES) != -1; +} + +/* make_blindedpath only needs pubkeys */ +static const struct pubkey *route_backwards(const tal_t *ctx, + const struct gossmap *gossmap, + struct route **r) +{ + struct pubkey *rarr; + + rarr = tal_arr(ctx, struct pubkey, tal_count(r)); + for (size_t i = 0; i < tal_count(r); i++) { + const struct gossmap_node *dst; + struct node_id id; + + dst = gossmap_nth_node(gossmap, r[i]->c, r[i]->dir); + gossmap_node_get_id(gossmap, dst, &id); + /* We're going backwards */ + if (!pubkey_from_node_id(&rarr[tal_count(rarr) - 1 - i], &id)) + abort(); + } + + return rarr; +} + +static struct command_result *send_message(struct command *cmd, + struct sent *sent, + const char *msgfield, + const u8 *msgval) +{ + const struct dijkstra *dij; + const struct gossmap_node *dst, *src; + struct route **r; + struct gossmap *gossmap = get_gossmap(cmd->plugin); + const struct pubkey *backwards; + struct onionmsg_path **path; + struct pubkey blinding, reply_blinding; + struct out_req *req; + struct node_id dstid; + + /* FIXME: Use blinded path if avail. */ + gossmap_guess_node_id(gossmap, sent->offer->node_id, &dstid); + dst = gossmap_find_node(gossmap, &dstid); + if (!dst) + return command_fail(cmd, LIGHTNINGD, + "Unknown destination %s", + type_to_string(tmpctx, struct node_id, + &dstid)); + + /* If we don't exist in gossip, routing can't happen. */ + src = gossmap_find_node(gossmap, &local_id); + if (!src) + return command_fail(cmd, PAY_ROUTE_NOT_FOUND, + "We don't have any channels"); + + dij = dijkstra(tmpctx, gossmap, dst, AMOUNT_MSAT(0), 0, + can_carry_onionmsg, route_score_shorter, NULL); + + r = route_from_dijkstra(tmpctx, gossmap, dij, src); + if (!r) + /* FIXME: We need to retry kind of like keysend here... */ + return command_fail(cmd, OFFER_ROUTE_NOT_FOUND, + "Can't find route"); + + /* Ok, now make reply for onion_message */ + backwards = route_backwards(tmpctx, gossmap, r); + path = make_blindedpath(tmpctx, backwards, &blinding, &reply_blinding); + + req = jsonrpc_request_start(cmd->plugin, cmd, "sendonionmessage", + &sendonionmsg_done, + &forward_error, + sent); + json_array_start(req->js, "hops"); + for (size_t i = 0; i < tal_count(r); i++) { + struct node_id id; + + json_object_start(req->js, NULL); + gossmap_node_get_id(gossmap, + gossmap_nth_node(gossmap, r[i]->c, !r[i]->dir), + &id); + json_add_node_id(req->js, "id", &id); + if (i == tal_count(r) - 1) + json_add_hex_talarr(req->js, msgfield, msgval); + json_object_end(req->js); + } + json_array_end(req->js); + + json_object_start(req->js, "reply_path"); + json_add_pubkey(req->js, "blinding", &blinding); + json_array_start(req->js, "path"); + for (size_t i = 0; i < tal_count(path); i++) { + json_object_start(req->js, NULL); + json_add_pubkey(req->js, "id", &path[i]->node_id); + if (path[i]->enctlv) + json_add_hex_talarr(req->js, "enctlv", path[i]->enctlv); + json_object_end(req->js); + } + json_array_end(req->js); + json_object_end(req->js); + return send_outreq(cmd->plugin, req); +} + +static struct command_result *invreq_done(struct command *cmd, + const char *buf, + const jsmntok_t *result, + struct tlv_offer *offer) +{ + const jsmntok_t *t; + struct sent *sent; + char *fail; + u8 *rawinvreq; + + /* We need to remember both offer and invreq to check reply. */ + sent = tal(cmd, struct sent); + sent->offer = tal_steal(sent, offer); + + /* Get invoice request */ + t = json_get_member(buf, result, "bolt12"); + if (!t) + return command_fail(cmd, LIGHTNINGD, + "Missing bolt12 %.*s", + json_tok_full_len(result), + json_tok_full(buf, result)); + + plugin_log(cmd->plugin, LOG_DBG, + "invoice_request: %.*s", + json_tok_full_len(t), + json_tok_full(buf, t)); + + sent->invreq = invrequest_decode(sent, + buf + t->start, + t->end - t->start, + plugin_feature_set(cmd->plugin), + chainparams, + &fail); + if (!sent->invreq) + return command_fail(cmd, LIGHTNINGD, + "Invalid invoice_request %.*s: %s", + json_tok_full_len(t), + json_tok_full(buf, t), + fail); + + rawinvreq = tal_arr(tmpctx, u8, 0); + towire_invoice_request(&rawinvreq, sent->invreq); + return send_message(cmd, sent, "invoice_request", rawinvreq); +} + +/* Fetches an invoice for this offer, and makes sure it corresponds. */ +static struct command_result *json_fetchinvoice(struct command *cmd, + const char *buffer, + const jsmntok_t *params) +{ + struct tlv_offer *offer; + struct amount_msat *msat; + const char *rec_label; + struct out_req *req; + struct tlv_invoice_request *invreq; + + invreq = tlv_invoice_request_new(cmd); + + if (!param(cmd, buffer, params, + p_req("offer", param_offer, &offer), + p_opt("msatoshi", param_msat, &msat), + p_opt("quantity", param_u64, &invreq->quantity), + p_opt("recurrence_counter", param_number, + &invreq->recurrence_counter), + p_opt("recurrence_start", param_number, + &invreq->recurrence_start), + p_opt("recurrence_label", param_string, &rec_label), + NULL)) + return command_param_failed(); + + /* BOLT-offers #12: + * - MUST set `offer_id` to the merkle root of the offer as described + * in [Signature Calculation](#signature-calculation). + */ + invreq->offer_id = tal(invreq, struct sha256); + merkle_tlv(offer->fields, invreq->offer_id); + + /* Check if they are trying to send us money. */ + if (offer->send_invoice) + return command_fail(cmd, JSONRPC2_INVALID_PARAMS, + "Offer wants an invoice, not invoice_request"); + + /* BOLT-offers #12: + * - SHOULD not respond to an offer if the current time is after + * `absolute_expiry`. + */ + if (offer->absolute_expiry + && time_now().ts.tv_sec > *offer->absolute_expiry) + return command_fail(cmd, OFFER_EXPIRED, "Offer expired"); + + /* BOLT-offers #12: + * - if the offer did not specify `amount`: + * - MUST specify `amount`.`msat` in multiples of the minimum + * lightning-payable unit (e.g. milli-satoshis for bitcoin) for the + * first `chains` entry. + * - otherwise: + * - MUST NOT set `amount` + */ + if (offer->amount) { + if (msat) + return command_fail(cmd, JSONRPC2_INVALID_PARAMS, + "msatoshi parameter unnecessary"); + } else { + if (!msat) + return command_fail(cmd, JSONRPC2_INVALID_PARAMS, + "msatoshi parameter required"); + invreq->amount = tal_dup(invreq, u64, + &msat->millisatoshis); /* Raw: tu64 */ + } + + /* BOLT-offers #12: + * - if the offer had a `quantity_min` or `quantity_max` field: + * - MUST set `quantity` + * - MUST set it within that (inclusive) range. + * - otherwise: + * - MUST NOT set `quantity` + */ + if (offer->quantity_min || offer->quantity_max) { + if (!invreq->quantity) + return command_fail(cmd, JSONRPC2_INVALID_PARAMS, + "quantity parameter required"); + if (offer->quantity_min + && *invreq->quantity < *offer->quantity_min) + return command_fail(cmd, JSONRPC2_INVALID_PARAMS, + "quantity must be >= %"PRIu64, + *offer->quantity_min); + if (offer->quantity_max + && *invreq->quantity > *offer->quantity_max) + return command_fail(cmd, JSONRPC2_INVALID_PARAMS, + "quantity must be <= %"PRIu64, + *offer->quantity_max); + } else { + if (invreq->quantity) + return command_fail(cmd, JSONRPC2_INVALID_PARAMS, + "quantity parameter unnecessary"); + } + + /* BOLT-offers #12: + * - if the offer contained `recurrence`: + */ + if (offer->recurrence) { + /* BOLT-offers #12: + * - for the initial request: + *... + * - MUST set `recurrence_counter` `counter` to 0. + */ + /* BOLT-offers #12: + * - for any successive requests: + *... + * - MUST set `recurrence_counter` `counter` to one greater + * than the highest-paid invoice. + */ + if (!invreq->recurrence_counter) + return command_fail(cmd, JSONRPC2_INVALID_PARAMS, + "needs recurrence_counter"); + + /* BOLT-offers #12: + * - if the offer contained `recurrence_base` with + * `start_any_period` non-zero: + * - MUST include `recurrence_start` + *... + * - otherwise: + * - MUST NOT include `recurrence_start` + */ + if (offer->recurrence_base + && offer->recurrence_base->start_any_period) { + if (!invreq->recurrence_start) + return command_fail(cmd, JSONRPC2_INVALID_PARAMS, + "needs recurrence_start"); + } else { + if (invreq->recurrence_start) + return command_fail(cmd, JSONRPC2_INVALID_PARAMS, + "unnecessary recurrence_start"); + } + + /* recurrence_label uniquely identifies this series of + * payments. */ + if (!rec_label) + return command_fail(cmd, JSONRPC2_INVALID_PARAMS, + "needs recurrence_label"); + + /* FIXME! */ + /* BOLT-offers #12: + * - SHOULD NOT send an `invoice_request` for a period which has + * already passed. + */ + /* If there's no recurrence_base, we need the initial payment + * for this... */ + } else { + /* BOLT-offers #12: + * - otherwise: + * - MUST NOT set `recurrence_counter`. + *... + * - MUST NOT set `recurrence_start` + */ + if (invreq->recurrence_counter) + return command_fail(cmd, JSONRPC2_INVALID_PARAMS, + "unnecessary recurrence_counter"); + if (invreq->recurrence_start) + return command_fail(cmd, JSONRPC2_INVALID_PARAMS, + "unnecessary recurrence_start"); + } + + /* BOLT-offers #12: + * + * - if the chain for the invoice is not solely bitcoin: + * - MUST specify `chains` the offer is valid for. + * - otherwise: + * - the bitcoin chain is implied as the first and only entry. + */ + if (!streq(chainparams->network_name, "bitcoin")) { + invreq->chains = tal_arr(invreq, struct bitcoin_blkid, 1); + invreq->chains[0] = chainparams->genesis_blockhash; + } + + invreq->features + = plugin_feature_set(cmd->plugin)->bits[BOLT11_FEATURE]; + + /* Make the invoice request (fills in payer_key and payer_info) */ + req = jsonrpc_request_start(cmd->plugin, cmd, "createinvoicerequest", + &invreq_done, + &forward_error, + offer); + json_add_string(req->js, "bolt12", invrequest_encode(tmpctx, invreq)); + if (rec_label) + json_add_string(req->js, "recurrence_label", rec_label); + return send_outreq(cmd->plugin, req); +} + +static const struct plugin_command commands[] = { { + "fetchinvoice", + "payment", + "Request remote node for an invoice for this {offer}, with {amount}, {quanitity}, {recurrence_counter}, {recurrence_start} and {recurrence_label} iff required.", + NULL, + json_fetchinvoice, + } +}; + +static void init(struct plugin *p, const char *buf UNUSED, + const jsmntok_t *config UNUSED) +{ + const char *field; + + field = rpc_delve(tmpctx, p, "getinfo", + take(json_out_obj(NULL, NULL, NULL)), ".id"); + if (!node_id_from_hexstr(field, strlen(field), &local_id)) + plugin_err(p, "getinfo didn't contain valid id: '%s'", field); +} + +int main(int argc, char *argv[]) +{ + setup_locale(); + plugin_main(argv, init, PLUGIN_RESTARTABLE, true, NULL, + commands, ARRAY_SIZE(commands), + /* No notifications */ + NULL, 0, + /* No hooks */ + NULL, 0, + /* No options */ + NULL); +}