From f2d2db7b4eccb4af136ee58bc5e18de8d283bfee Mon Sep 17 00:00:00 2001 From: Rusty Russell Date: Fri, 8 Jan 2021 05:06:47 +1030 Subject: [PATCH] plugins/offer: handle receiving an invoice in an onion_message. And if we have a matching `send_invoice` offer, try to pay it! Signed-off-by: Rusty Russell --- plugins/Makefile | 2 +- plugins/offers.c | 11 +- plugins/offers_inv_hook.c | 410 ++++++++++++++++++++++++++++++++++++++ plugins/offers_inv_hook.h | 11 + 4 files changed, 432 insertions(+), 2 deletions(-) create mode 100644 plugins/offers_inv_hook.c create mode 100644 plugins/offers_inv_hook.h diff --git a/plugins/Makefile b/plugins/Makefile index f83f78429..b6f2471b5 100644 --- a/plugins/Makefile +++ b/plugins/Makefile @@ -25,7 +25,7 @@ PLUGIN_PAY_LIB_SRC := plugins/libplugin-pay.c PLUGIN_PAY_LIB_HEADER := plugins/libplugin-pay.h PLUGIN_PAY_LIB_OBJS := $(PLUGIN_PAY_LIB_SRC:.c=.o) -PLUGIN_OFFERS_SRC := plugins/offers.c plugins/offers_offer.c plugins/offers_invreq_hook.c +PLUGIN_OFFERS_SRC := plugins/offers.c plugins/offers_offer.c plugins/offers_invreq_hook.c plugins/offers_inv_hook.c PLUGIN_OFFERS_OBJS := $(PLUGIN_OFFERS_SRC:.c=.o) PLUGIN_OFFERS_HEADER := $(PLUGIN_OFFERS_SRC:.c=.h) diff --git a/plugins/offers.c b/plugins/offers.c index c9bde41b4..dc835a3c0 100644 --- a/plugins/offers.c +++ b/plugins/offers.c @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -75,7 +76,7 @@ static struct command_result *onion_message_call(struct command *cmd, const char *buf, const jsmntok_t *params) { - const jsmntok_t *om, *invreqtok; + const jsmntok_t *om, *invreqtok, *invtok; om = json_get_member(buf, params, "onion_message"); @@ -92,6 +93,14 @@ static struct command_result *onion_message_call(struct command *cmd, "invoice_request without reply_path"); } + invtok = json_get_member(buf, om, "invoice"); + if (invtok) { + const jsmntok_t *replytok; + + replytok = json_get_member(buf, om, "reply_path"); + return handle_invoice(cmd, buf, invtok, replytok); + } + return command_hook_success(cmd); } diff --git a/plugins/offers_inv_hook.c b/plugins/offers_inv_hook.c new file mode 100644 index 000000000..5d0dd7f25 --- /dev/null +++ b/plugins/offers_inv_hook.c @@ -0,0 +1,410 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* We need to keep the reply path around so we can reply if error */ +struct inv { + struct tlv_invoice *inv; + + const char *buf; + /* May be NULL */ + const jsmntok_t *replytok; + + /* The offer, once we've looked it up. */ + struct tlv_offer *offer; +}; + +static struct command_result *WARN_UNUSED_RESULT +fail_inv_level(struct command *cmd, + const struct inv *inv, + enum log_level l, + const char *fmt, va_list ap) +{ + char *full_fmt, *msg; + struct tlv_invoice_error *err; + u8 *errdata; + + full_fmt = tal_fmt(tmpctx, "Failed invoice %s", + invoice_encode(tmpctx, inv->inv)); + if (inv->inv->offer_id) + tal_append_fmt(&full_fmt, " for offer %s", + type_to_string(tmpctx, struct sha256, + inv->inv->offer_id)); + tal_append_fmt(&full_fmt, ": %s", fmt); + + msg = tal_vfmt(tmpctx, full_fmt, ap); + plugin_log(cmd->plugin, l, "%s", msg); + + /* Only reply if they gave us a path */ + if (!inv->replytok) + return command_hook_success(cmd); + + /* Don't send back internal error details. */ + if (l == LOG_BROKEN) + msg = "Internal error"; + + err = tlv_invoice_error_new(cmd); + /* Remove NUL terminator */ + err->error = tal_dup_arr(err, char, msg, strlen(msg), 0); + /* FIXME: Add suggested_value / erroneous_field! */ + + errdata = tal_arr(cmd, u8, 0); + towire_invoice_error(&errdata, err); + return send_onion_reply(cmd, inv->buf, inv->replytok, "invoice_error", errdata); +} + +static struct command_result *WARN_UNUSED_RESULT +fail_inv(struct command *cmd, + const struct inv *inv, + const char *fmt, ...) +{ + va_list ap; + struct command_result *ret; + + va_start(ap, fmt); + ret = fail_inv_level(cmd, inv, LOG_DBG, fmt, ap); + va_end(ap); + + return ret; +} + +static struct command_result *WARN_UNUSED_RESULT +fail_internalerr(struct command *cmd, + const struct inv *inv, + const char *fmt, ...) +{ + va_list ap; + struct command_result *ret; + + va_start(ap, fmt); + ret = fail_inv_level(cmd, inv, LOG_BROKEN, fmt, ap); + va_end(ap); + + return ret; +} + +#define inv_must_have(cmd_, i_, fld_) \ + test_field(cmd_, i_, i_->inv->fld_ != NULL, #fld_, "missing") +#define inv_must_not_have(cmd_, i_, fld_) \ + test_field(cmd_, i_, i_->inv->fld_ == NULL, #fld_, "unexpected") +#define inv_must_equal_offer(cmd_, i_, fld_) \ + test_field_eq(cmd_, i_, i_->inv->fld_, i_->offer->fld_, #fld_) + +static struct command_result * +test_field(struct command *cmd, + const struct inv *inv, + bool test, const char *fieldname, const char *what) +{ + if (!test) + return fail_inv(cmd, inv, "%s %s", what, fieldname); + return NULL; +} + +static struct command_result * +test_field_eq(struct command *cmd, + const struct inv *inv, + const tal_t *invfield, + const tal_t *offerfield, + const char *fieldname) +{ + if (invfield && !offerfield) + return fail_inv(cmd, inv, "Unexpected %s", fieldname); + if (!invfield && offerfield) + return fail_inv(cmd, inv, "Expected %s", fieldname); + if (!memeq(invfield, tal_bytelen(invfield), + offerfield, tal_bytelen(offerfield))) + return fail_inv(cmd, inv, "Different %s", fieldname); + return NULL; +} + +static struct command_result *pay_done(struct command *cmd, + const char *buf, + const jsmntok_t *result, + struct inv *inv) +{ + struct amount_msat msat = amount_msat(*inv->inv->amount); + + plugin_log(cmd->plugin, LOG_INFORM, + "Payed out %s for offer %s%s: %.*s", + type_to_string(tmpctx, struct amount_msat, &msat), + type_to_string(tmpctx, struct sha256, inv->inv->offer_id), + inv->offer->refund_for ? " (refund)": "", + json_tok_full_len(result), + json_tok_full(buf, result)); + return command_hook_success(cmd); +} + +static struct command_result *pay_error(struct command *cmd, + const char *buf, + const jsmntok_t *error, + struct inv *inv) +{ + const jsmntok_t *msgtok = json_get_member(buf, error, "message"); + + return fail_inv(cmd, inv, "pay attempt failed: %.*s", + json_tok_full_len(msgtok), + json_tok_full(buf, msgtok)); +} + +static struct command_result *listoffers_done(struct command *cmd, + const char *buf, + const jsmntok_t *result, + struct inv *inv) +{ + const jsmntok_t *arr = json_get_member(buf, result, "offers"); + const jsmntok_t *offertok, *activetok, *b12tok; + bool active; + struct amount_msat amt; + char *fail; + struct out_req *req; + struct command_result *err; + + /* BOLT-offers #12: + * - otherwise if `offer_id` is set: + * - MUST reject the invoice if the `offer_id` does not refer an + * unexpired offer with `send_invoice` + */ + if (arr->size == 0) + return fail_inv(cmd, inv, "Unknown offer"); + + plugin_log(cmd->plugin, LOG_INFORM, + "Attempting payment of offer %.*s", + json_tok_full_len(result), + json_tok_full(buf, result)); + + offertok = arr + 1; + activetok = json_get_member(buf, offertok, "active"); + if (!activetok) { + return fail_internalerr(cmd, inv, + "Missing active: %.*s", + json_tok_full_len(offertok), + json_tok_full(buf, offertok)); + } + json_to_bool(buf, activetok, &active); + if (!active) + return fail_inv(cmd, inv, "Offer no longer available"); + + b12tok = json_get_member(buf, offertok, "bolt12"); + if (!b12tok) { + return fail_internalerr(cmd, inv, + "Missing bolt12: %.*s", + json_tok_full_len(offertok), + json_tok_full(buf, offertok)); + } + inv->offer = offer_decode(inv, + buf + b12tok->start, + b12tok->end - b12tok->start, + plugin_feature_set(cmd->plugin), + chainparams, &fail); + if (!inv->offer) { + return fail_internalerr(cmd, inv, + "Invalid offer: %s (%.*s)", + fail, + json_tok_full_len(offertok), + json_tok_full(buf, offertok)); + } + + if (inv->offer->absolute_expiry + && time_now().ts.tv_sec >= *inv->offer->absolute_expiry) { + /* FIXME: do deloffer to disable it */ + return fail_inv(cmd, inv, "Offer expired"); + } + + if (!inv->offer->send_invoice) { + return fail_inv(cmd, inv, "Offer did not expect invoice"); + } + + /* BOLT-offers #12: + * - MUST reject the invoice unless the following fields are equal + * or unset exactly as they are in the `offer`: + * - `refund_for` + * - `description` + * - `vendor` + */ + err = inv_must_equal_offer(cmd, inv, refund_for); + if (err) + return err; + err = inv_must_equal_offer(cmd, inv, description); + if (err) + return err; + err = inv_must_equal_offer(cmd, inv, vendor); + if (err) + return err; + + /* BOLT-offers #12: + * - if the offer had a `quantity_min` or `quantity_max` field: + * - MUST fail the request if there is no `quantity` field. + * - MUST fail the request if there is `quantity` is not within + * that (inclusive) range. + * - otherwise: + * - MUST fail the request if there is a `quantity` field. + */ + if (inv->offer->quantity_min || inv->offer->quantity_max) { + err = inv_must_have(cmd, inv, quantity); + if (err) + return err; + + if (inv->offer->quantity_min && + *inv->inv->quantity < *inv->offer->quantity_min) { + return fail_inv(cmd, inv, + "quantity %"PRIu64 " < %"PRIu64, + *inv->inv->quantity, + *inv->offer->quantity_min); + } + + if (inv->offer->quantity_max && + *inv->inv->quantity > *inv->offer->quantity_max) { + return fail_inv(cmd, inv, + "quantity %"PRIu64" > %"PRIu64, + *inv->inv->quantity, + *inv->offer->quantity_max); + } + } else { + err = inv_must_not_have(cmd, inv, quantity); + if (err) + return err; + } + + /* BOLT-offers #12: + * - MUST reject the invoice if `msat` is not present. + */ + err = inv_must_have(cmd, inv, amount); + if (err) + return err; + + /* FIXME: Handle alternate currency conversion here! */ + if (inv->offer->currency) + return fail_inv(cmd, inv, "FIXME: support currency"); + + amt = amount_msat(*inv->inv->amount); + /* If you send an offer without an amount, you want to give away + * unlimited money. Err, ok? */ + if (inv->offer->amount) { + struct amount_msat expected = amount_msat(*inv->offer->amount); + + /* We could allow invoices for less, I suppose. */ + if (!amount_msat_eq(expected, amt)) + return fail_inv(cmd, inv, "Expected invoice for %s", + fmt_amount_msat(tmpctx, &expected)); + } + + plugin_log(cmd->plugin, LOG_INFORM, + "Attempting payment of %s for offer %s%s", + type_to_string(tmpctx, struct amount_msat, &amt), + type_to_string(tmpctx, struct sha256, inv->inv->offer_id), + inv->offer->refund_for ? " (refund)": ""); + + req = jsonrpc_request_start(cmd->plugin, cmd, "pay", + pay_done, pay_error, inv); + json_add_string(req->js, "bolt11", invoice_encode(tmpctx, inv->inv)); + json_add_sha256(req->js, "localofferid", inv->inv->offer_id); + return send_outreq(cmd->plugin, req); +} + +static struct command_result *listoffers_error(struct command *cmd, + const char *buf, + const jsmntok_t *err, + struct inv *inv) +{ + return fail_internalerr(cmd, inv, + "listoffers gave JSON error: %.*s", + json_tok_full_len(err), + json_tok_full(buf, err)); +} + +struct command_result *handle_invoice(struct command *cmd, + const char *buf, + const jsmntok_t *invtok, + const jsmntok_t *replytok) +{ + const u8 *invbin = json_tok_bin_from_hex(cmd, buf, invtok); + size_t len = tal_count(invbin); + struct inv *inv = tal(cmd, struct inv); + struct out_req *req; + struct command_result *err; + int bad_feature; + struct sha256 m, shash; + + /* Make a copy of entire buffer, for later. */ + inv->buf = tal_dup_arr(inv, char, buf, replytok->end, 0); + inv->replytok = replytok; + + inv->inv = tlv_invoice_new(cmd); + if (!fromwire_invoice(&invbin, &len, inv->inv)) { + return fail_inv(cmd, inv, + "Invalid invoice %s", + tal_hex(tmpctx, invbin)); + } + + /* BOLT-offers #12: + * + * The reader of an invoice_request: + *... + * - MUST fail the request if `features` contains unknown even bits. + */ + bad_feature = features_unsupported(plugin_feature_set(cmd->plugin), + inv->inv->features, + BOLT11_FEATURE); + if (bad_feature != -1) { + return fail_inv(cmd, inv, + "Unsupported inv feature %i", + bad_feature); + } + + /* BOLT-offers #12: + * + * The reader of an invoice_request: + *... + * - MUST fail the request if `chains` does not include (or imply) a + * supported chain. + */ + if (!bolt12_chains_match(inv->inv->chains, chainparams)) { + return fail_inv(cmd, inv, + "Wrong chains %s", + tal_hex(tmpctx, inv->inv->chains)); + } + + /* BOLT-offers #12: + * - MUST reject the invoice if `signature` is not a valid signature + * using `node_id` as described in + * [Signature Calculation](#signature-calculation). + */ + err = inv_must_have(cmd, inv, node_id); + if (err) + return err; + + err = inv_must_have(cmd, inv, signature); + if (err) + return err; + + merkle_tlv(inv->inv->fields, &m); + sighash_from_merkle("invoice", "signature", &m, &shash); + if (secp256k1_schnorrsig_verify(secp256k1_ctx, + inv->inv->signature->u8, + shash.u.u8, + &inv->inv->node_id->pubkey) != 1) { + return fail_inv(cmd, inv, "Bad signature"); + } + + /* We don't pay random invoices off the internet, sorry. */ + err = inv_must_have(cmd, inv, offer_id); + if (err) + return err; + + /* Now find the offer. */ + req = jsonrpc_request_start(cmd->plugin, cmd, "listoffers", + listoffers_done, listoffers_error, inv); + json_add_sha256(req->js, "offer_id", inv->inv->offer_id); + return send_outreq(cmd->plugin, req); +} + diff --git a/plugins/offers_inv_hook.h b/plugins/offers_inv_hook.h new file mode 100644 index 000000000..a5d15d5bf --- /dev/null +++ b/plugins/offers_inv_hook.h @@ -0,0 +1,11 @@ +#ifndef LIGHTNING_PLUGINS_OFFERS_INV_HOOK_H +#define LIGHTNING_PLUGINS_OFFERS_INV_HOOK_H +#include "config.h" +#include + +/* We got an onionmessage with an invoice! replytok could be NULL. */ +struct command_result *handle_invoice(struct command *cmd, + const char *buf, + const jsmntok_t *invtok, + const jsmntok_t *replytok); +#endif /* LIGHTNING_PLUGINS_OFFERS_INV_HOOK_H */