From 4bb05e46e945354eb4569f42d1972d6294029dd7 Mon Sep 17 00:00:00 2001 From: Rusty Russell Date: Sat, 9 Jan 2021 14:55:46 +1030 Subject: [PATCH] offers: convert currency when they request an invoice. Means a reshuffle of our logic: we want to multiply by quantity before conversion for maximum accuracy. Signed-off-by: Rusty Russell --- doc/lightning-offer.7 | 6 +- doc/lightning-offer.7.md | 4 +- plugins/offers_invreq_hook.c | 250 ++++++++++++++++++++++++----------- tests/test_pay.py | 24 +++- 4 files changed, 203 insertions(+), 81 deletions(-) diff --git a/doc/lightning-offer.7 b/doc/lightning-offer.7 index 7b12c80e5..944e02a01 100644 --- a/doc/lightning-offer.7 +++ b/doc/lightning-offer.7 @@ -24,7 +24,9 @@ places ending in \fIbtc\fR\. \fIamount\fR can also have an ISO 4217 postfix (i\.e\. USD), in which case -currency conversion will need to be done for the invoice itself\. +currency conversion will need to be done for the invoice itself\. A +plugin is needed which provides the "currencyconvert" API for this +currency, otherwise the offer creation will fail\. The \fIdescription\fR is a short description of purpose of the offer, @@ -147,4 +149,4 @@ Rusty Russell \fI is mainly responsible\. Main web site: \fIhttps://github.com/ElementsProject/lightning\fR -\" SHA256STAMP:54947f0571c064b5190b672f79dd8c4b4555aad3e93007c28deab37c9a0566c1 +\" SHA256STAMP:60534030c8c7ebc34b521a5bb5d76bd1d59e99ac80d16f5b0a9a3ac3bd164b48 diff --git a/doc/lightning-offer.7.md b/doc/lightning-offer.7.md index 12b5d1e9a..b79d16240 100644 --- a/doc/lightning-offer.7.md +++ b/doc/lightning-offer.7.md @@ -23,7 +23,9 @@ three decimal places ending in *sat*, or a number with 1 to 11 decimal places ending in *btc*. *amount* can also have an ISO 4217 postfix (i.e. USD), in which case -currency conversion will need to be done for the invoice itself. +currency conversion will need to be done for the invoice itself. A +plugin is needed which provides the "currencyconvert" API for this +currency, otherwise the offer creation will fail. The *description* is a short description of purpose of the offer, e.g. *coffee*. This value is encoded into the resulting offer and is diff --git a/plugins/offers_invreq_hook.c b/plugins/offers_invreq_hook.c index 090086f9f..7053cf8cb 100644 --- a/plugins/offers_invreq_hook.c +++ b/plugins/offers_invreq_hook.c @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -405,6 +406,170 @@ static bool check_recurrence_sig(const struct tlv_invoice_request *invreq, sighash.u.u8, &payer_key->pubkey) == 1; } +static struct command_result *invreq_amount_by_quantity(struct command *cmd, + const struct invreq *ir, + u64 *raw_amt) +{ + struct command_result *err; + + assert(ir->offer->amount); + + /* BOLT-offers #12: + * + * - if the offer included `amount`: + * - MUST fail the request if it contains `amount`. + */ + err = invreq_must_not_have(cmd, ir, amount); + if (err) + return err; + + *raw_amt = *ir->offer->amount; + + /* BOLT-offers #12: + * - if request contains `quantity`, multiply by `quantity`. + */ + if (ir->invreq->quantity) { + if (mul_overflows_u64(*ir->invreq->quantity, *raw_amt)) { + return fail_invreq(cmd, ir, + "quantity %"PRIu64 + " causes overflow", + *ir->invreq->quantity); + } + *raw_amt *= *ir->invreq->quantity; + } + + return NULL; +} + +/* The non-currency-converting case. */ +static struct command_result *invreq_base_amount_simple(struct command *cmd, + const struct invreq *ir, + struct amount_msat *amt) +{ + struct command_result *err; + + if (ir->offer->amount) { + u64 raw_amount; + assert(!ir->offer->currency); + err = invreq_amount_by_quantity(cmd, ir, &raw_amount); + if (err) + return err; + + *amt = amount_msat(raw_amount); + } else { + /* BOLT-offers #12: + * + * - otherwise: + * - MUST fail the request if it does not contain `amount`. + * - MUST use the request `amount` as the *base invoice amount*. + * (Note: invoice amount can be further modiifed by recurrence + * below) + */ + err = invreq_must_have(cmd, ir, amount); + if (err) + return err; + + *amt = amount_msat(*ir->invreq->amount); + } + return NULL; +} + +static struct command_result *handle_amount_and_recurrence(struct command *cmd, + struct invreq *ir, + struct amount_msat amount) +{ + ir->inv->amount = tal_dup(ir->inv, u64, + &amount.millisatoshis); /* Raw: wire protocol */ + + /* Last of all, we handle recurrence details, which often requires + * further lookups. */ + + /* BOLT-offers #12: + * - MUST set (or not set) `recurrence_counter` exactly as the + * invoice_request did. + */ + if (ir->invreq->recurrence_counter) { + ir->inv->recurrence_counter = ir->invreq->recurrence_counter; + return check_previous_invoice(cmd, ir); + } + /* We're happy with 2 hours timeout (default): they can always + * request another. */ + + /* FIXME: Fallbacks? */ + return create_invoicereq(cmd, ir); +} + +static struct command_result *currency_done(struct command *cmd, + const char *buf, + const jsmntok_t *result, + struct invreq *ir) +{ + const jsmntok_t *msat = json_get_member(buf, result, "msat"); + struct amount_msat amount; + + /* Fail in this case, forwarding warnings. */ + if (!msat) + return fail_internalerr(cmd, ir, + "Cannot convert currency %.*s: %.*s", + (int)tal_bytelen(ir->offer->currency), + (const char *)ir->offer->currency, + json_tok_full_len(result), + json_tok_full(buf, result)); + + if (!json_to_msat(buf, msat, &amount)) + return fail_internalerr(cmd, ir, + "Bad convert for currency %.*s: %.*s", + (int)tal_bytelen(ir->offer->currency), + (const char *)ir->offer->currency, + json_tok_full_len(msat), + json_tok_full(buf, msat)); + + return handle_amount_and_recurrence(cmd, ir, amount); +} + +static struct command_result *convert_currency(struct command *cmd, + struct invreq *ir) +{ + struct out_req *req; + u64 raw_amount; + double double_amount; + struct command_result *err; + const struct iso4217_name_and_divisor *iso4217; + + assert(ir->offer->currency); + + /* Multiply by quantity *first*, for best precision */ + err = invreq_amount_by_quantity(cmd, ir, &raw_amount); + if (err) + return err; + + /* BOLT-offers #12: + * - MUST calculate the *base invoice amount* using the offer + * `amount`: + * - if offer `currency` is not the invoice currency, convert + * to the invoice currency. + */ + iso4217 = find_iso4217(ir->offer->currency, + tal_bytelen(ir->offer->currency)); + /* We should not create offer with unknown currency! */ + if (!iso4217) + return fail_internalerr(cmd, ir, + "Unknown offer currency %.*s", + (int)tal_bytelen(ir->offer->currency), + ir->offer->currency); + double_amount = (double)raw_amount; + for (size_t i = 0; i < iso4217->minor_unit; i++) + double_amount /= 10; + + req = jsonrpc_request_start(cmd->plugin, cmd, "currencyconvert", + currency_done, error, ir); + json_add_stringn(req->js, "currency", + (const char *)ir->offer->currency, + tal_bytelen(ir->offer->currency)); + json_add_member(req->js, "amount", false, "%f", double_amount); + return send_outreq(cmd->plugin, req); +} + static struct command_result *listoffers_done(struct command *cmd, const char *buf, const jsmntok_t *result, @@ -413,9 +578,9 @@ static struct command_result *listoffers_done(struct command *cmd, 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 command_result *err; + struct amount_msat amt; /* BOLT-offers #12: * @@ -498,64 +663,6 @@ static struct command_result *listoffers_done(struct command *cmd, return err; } - if (ir->offer->amount) { - u64 raw_amount; - - /* BOLT-offers #12: - * - * - if the offer included `amount`: - * - MUST fail the request if it contains `amount`. - */ - err = invreq_must_not_have(cmd, ir, amount); - if (err) - return err; - - - /* BOLT-offers #12: - * - MUST calculate the *base invoice amount* using the offer - * `amount`: - * - if offer `currency` is not the invoice currency, convert - * to the invoice currency. - */ - if (ir->offer->currency) { - /* FIXME: Currency conversion! */ - return fail_invreq(cmd, ir, - "FIXME: Request for currency %.*s", - (int)tal_bytelen(ir->offer->currency), - (char *)ir->offer->currency); - } else - raw_amount = *ir->offer->amount; - - /* BOLT-offers #12: - * - if request contains `quantity`, multiply by `quantity`. - */ - if (ir->invreq->quantity) { - if (mul_overflows_u64(*ir->invreq->quantity, raw_amount)) { - return fail_invreq(cmd, ir, - "quantity %"PRIu64 - " causes overflow", - *ir->invreq->quantity); - } - raw_amount *= *ir->invreq->quantity; - } - - amt = amount_msat(raw_amount); - } else { - /* BOLT-offers #12: - * - * - otherwise: - * - MUST fail the request if it does not contain `amount`. - * - MUST use the request `amount` as the *base invoice amount*. - * (Note: invoice amount can be further modiifed by recurrence - * below) - */ - err = invreq_must_have(cmd, ir, amount); - if (err) - return err; - - amt = amount_msat(*ir->invreq->amount); - } - if (ir->offer->recurrence) { /* BOLT-offers #12: * @@ -612,8 +719,6 @@ static struct command_result *listoffers_done(struct command *cmd, /* Which is the same as the invreq */ ir->inv->offer_id = tal_dup(ir->inv, struct sha256, ir->invreq->offer_id); - ir->inv->amount = tal_dup(ir->inv, u64, - &amt.millisatoshis); /* Raw: wire protocol */ ir->inv->description = tal_dup_talarr(ir->inv, char, ir->offer->description); ir->inv->features = tal_dup_talarr(ir->inv, u8, @@ -651,23 +756,14 @@ static struct command_result *listoffers_done(struct command *cmd, ir->inv->timestamp = tal(ir->inv, u64); *ir->inv->timestamp = time_now().ts.tv_sec; - /* Last of all, we handle recurrence details, which often requires - * further lookups. */ + /* We may require currency lookup; if so, do it now. */ + if (ir->offer->amount && ir->offer->currency) + return convert_currency(cmd, ir); - /* BOLT-offers #12: - * - MUST set (or not set) `recurrence_counter` exactly as the - * invoice_request did. - */ - if (ir->invreq->recurrence_counter) { - ir->inv->recurrence_counter = ir->invreq->recurrence_counter; - return check_previous_invoice(cmd, ir); - } - /* We're happy with 2 hours timeout (default): they can always - * request another. */ - - /* FIXME: Fallbacks? */ - /* FIXME: refunds? */ - return create_invoicereq(cmd, ir); + err = invreq_base_amount_simple(cmd, ir, &amt); + if (err) + return err; + return handle_amount_and_recurrence(cmd, ir, amt); } static struct command_result *handle_offerless_request(struct command *cmd, diff --git a/tests/test_pay.py b/tests/test_pay.py index acaf1f5a0..4497711af 100644 --- a/tests/test_pay.py +++ b/tests/test_pay.py @@ -3853,7 +3853,10 @@ def test_offer(node_factory, bitcoind): @unittest.skipIf(not EXPERIMENTAL_FEATURES, "offers are experimental") def test_fetchinvoice(node_factory, bitcoind): - l1, l2, l3 = node_factory.line_graph(3, wait_for_announce=True) + # We remove the conversion plugin on l3, causing it to get upset. + l1, l2, l3 = node_factory.line_graph(3, wait_for_announce=True, + opts=[{}, {}, + {'allow_broken_log': True}]) # Simple offer first. offer1 = l3.rpc.call('offer', {'amount': '1msat', @@ -3943,6 +3946,25 @@ def test_fetchinvoice(node_factory, bitcoind): 'recurrence_counter': 0, 'recurrence_label': 'test nochannel'}) + # Now, test amount in different currency! + plugin = os.path.join(os.path.dirname(__file__), 'plugins/currencyUSDAUD5000.py') + l3.rpc.plugin_start(plugin) + + offerusd = l3.rpc.call('offer', {'amount': '10.05USD', + 'description': 'USD test'})['bolt12'] + + inv = l1.rpc.call('fetchinvoice', {'offer': offerusd}) + assert inv['changes']['msat'] == Millisatoshi(int(10.05 * 5000)) + + # If we remove plugin, it can no longer give us an invoice. + l3.rpc.plugin_stop(plugin) + + with pytest.raises(RpcError, match="Internal error"): + l1.rpc.call('fetchinvoice', {'offer': offerusd}) + l3.daemon.wait_for_log("Unknown command 'currencyconvert'") + # But we can still pay the (already-converted) invoice. + l1.rpc.pay(inv['invoice']) + # Test timeout. l3.stop() with pytest.raises(RpcError, match='Timeout waiting for response'):