From 08e110b5686ea0dbf7bc6f493ae442cc6029724f Mon Sep 17 00:00:00 2001 From: Rusty Russell Date: Wed, 16 Dec 2020 13:43:28 +1030 Subject: [PATCH] JSON: offers plugin `offer` command. Signed-off-by: Rusty Russell --- plugins/Makefile | 6 +- plugins/offers.c | 30 +++-- plugins/offers_offer.c | 282 +++++++++++++++++++++++++++++++++++++++++ plugins/offers_offer.h | 11 ++ tests/test_pay.py | 156 +++++++++++++++++++++++ 5 files changed, 475 insertions(+), 10 deletions(-) create mode 100644 plugins/offers_offer.c create mode 100644 plugins/offers_offer.h diff --git a/plugins/Makefile b/plugins/Makefile index 9d818e2a4..136015ab8 100644 --- a/plugins/Makefile +++ b/plugins/Makefile @@ -25,8 +25,9 @@ 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 +PLUGIN_OFFERS_SRC := plugins/offers.c plugins/offers_offer.c PLUGIN_OFFERS_OBJS := $(PLUGIN_OFFERS_SRC:.c=.o) +PLUGIN_OFFERS_HEADER := plugins/offers_offer.h PLUGIN_SPENDER_SRC := \ plugins/spender/fundchannel.c \ @@ -59,6 +60,7 @@ endif PLUGIN_ALL_HEADER := \ $(PLUGIN_LIB_HEADER) \ $(PLUGIN_PAY_LIB_HEADER) \ + $(PLUGIN_OFFERS_HEADER) \ $(PLUGIN_SPENDER_HEADER) PLUGIN_ALL_OBJS := $(PLUGIN_ALL_SRC:.c=.o) @@ -134,7 +136,7 @@ $(PLUGIN_KEYSEND_OBJS): $(PLUGIN_PAY_LIB_HEADER) plugins/spenderp: bitcoin/chainparams.o bitcoin/psbt.o common/psbt_open.o $(PLUGIN_SPENDER_OBJS) $(PLUGIN_LIB_OBJS) $(PLUGIN_COMMON_OBJS) $(JSMN_OBJS) $(CCAN_OBJS) -plugins/offers: bitcoin/chainparams.o $(PLUGIN_OFFERS_OBJS) $(PLUGIN_LIB_OBJS) $(PLUGIN_COMMON_OBJS) common/bolt12.o common/bolt12_merkle.o wire/bolt12_exp_wiregen.o $(WIRE_OBJS) bitcoin/block.o common/channel_id.o bitcoin/preimage.o $(JSMN_OBJS) $(CCAN_OBJS) +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) $(PLUGIN_ALL_OBJS): $(PLUGIN_LIB_HEADER) diff --git a/plugins/offers.c b/plugins/offers.c index 4478af7dd..bb42e35dd 100644 --- a/plugins/offers.c +++ b/plugins/offers.c @@ -1,14 +1,9 @@ /* This plugin covers both sending and receiving offers */ -#include -#include #include -#include -#include -#include -#include -#include #include -#include +#include + +struct pubkey32 id; static const struct plugin_hook hooks[] = { }; @@ -17,9 +12,28 @@ static void init(struct plugin *p, const char *buf UNUSED, const jsmntok_t *config UNUSED) { + const char *field; + struct pubkey k; + + field = + rpc_delve(tmpctx, p, "getinfo", + take(json_out_obj(NULL, NULL, NULL)), + ".id"); + if (!pubkey_from_hexstr(field, strlen(field), &k)) + abort(); + if (secp256k1_xonly_pubkey_from_pubkey(secp256k1_ctx, &id.pubkey, + NULL, &k.pubkey) != 1) + abort(); } static const struct plugin_command commands[] = { + { + "offer", + "payment", + "Create an offer", + "Create an offer for invoices of {amount} with {destination}, optional {vendor}, {quantity_min}, {quantity_max}, {absolute_expiry}, {recurrence}, {recurrence_base}, {recurrence_paywindow}, {recurrence_limit} and {single_use}", + json_offer + }, }; int main(int argc, char *argv[]) diff --git a/plugins/offers_offer.c b/plugins/offers_offer.c new file mode 100644 index 000000000..6055f6168 --- /dev/null +++ b/plugins/offers_offer.c @@ -0,0 +1,282 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +static struct command_result *param_amount(struct command *cmd, + const char *name, + const char *buffer, + const jsmntok_t *tok, + struct tlv_offer *offer) +{ + struct amount_msat msat; + const struct iso4217_name_and_divisor *isocode; + jsmntok_t number, whole, frac; + u64 cents; + + if (json_tok_streq(buffer, tok, "any")) + return NULL; + + offer->amount = tal(offer, u64); + if (parse_amount_msat(&msat, buffer + tok->start, tok->end - tok->start)) { + *offer->amount = msat.millisatoshis; /* Raw: other currencies */ + return NULL; + } + + /* BOLT-offers #12: + * + * - MUST specify `iso4217` as an ISO 4712 three-letter code. + * - MUST specify `amount` in the currency unit adjusted by the ISO 4712 + * exponent (e.g. USD cents). + */ + if (tok->end - tok->start < ISO4217_NAMELEN) + return command_fail_badparam(cmd, name, buffer, tok, + "should be 'any', msatoshis or [.]"); + + isocode = find_iso4217(buffer + tok->end - ISO4217_NAMELEN, ISO4217_NAMELEN); + if (!isocode) + return command_fail(cmd, JSONRPC2_INVALID_PARAMS, + "Unknown currency suffix %.*s", + ISO4217_NAMELEN, + buffer + tok->end - ISO4217_NAMELEN); + + offer->currency + = tal_dup_arr(offer, utf8, isocode->name, ISO4217_NAMELEN, 0); + + number = *tok; + number.end -= ISO4217_NAMELEN; + if (!split_tok(buffer, &number, '.', &whole, &frac)) { + whole = number; + cents = 0; + } else { + if (frac.end - frac.start != isocode->minor_unit) + return command_fail(cmd, JSONRPC2_INVALID_PARAMS, + "Currency %s requires %u minor units", + isocode->name, isocode->minor_unit); + if (!json_to_u64(buffer, &frac, ¢s)) + return command_fail_badparam(cmd, name, buffer, + &number, + "Bad minor units"); + } + + if (!json_to_u64(buffer, &whole, offer->amount)) + return command_fail_badparam(cmd, name, buffer, tok, + "should be 'any', msatoshis or [.]"); + + for (size_t i = 0; i < isocode->minor_unit; i++) { + if (mul_overflows_u64(*offer->amount, 10)) + return command_fail_badparam(cmd, name, buffer, + &whole, + "excessively large value"); + *offer->amount *= 10; + } + + *offer->amount += cents; + return NULL; +} + +/* BOLT 13: + * - MUST set `time_unit` to 0 (seconds), 1 (days), 2 (months), 3 (years). + */ +struct time_string { + const char *suffix; + u32 unit; + u64 mul; +}; + +static const struct time_string *json_to_time(const char *buffer, + const jsmntok_t *tok, + u32 *mul) +{ + static const struct time_string suffixes[] = { + { "second", 0, 1 }, + { "seconds", 0, 1 }, + { "minute", 0, 60 }, + { "minutes", 0, 60 }, + { "hour", 0, 60*60 }, + { "hours", 0, 60*60 }, + { "day", 1, 1 }, + { "days", 1, 1 }, + { "week", 1, 7 }, + { "weeks", 1, 7 }, + { "month", 2, 1 }, + { "months", 2, 1 }, + { "year", 3, 1 }, + { "years", 3, 1 }, + }; + + for (size_t i = 0; i < ARRAY_SIZE(suffixes); i++) { + if (json_tok_endswith(buffer, tok, suffixes[i].suffix)) { + jsmntok_t t = *tok; + t.end -= strlen(suffixes[i].suffix); + if (!json_to_u32(buffer, &t, mul)) + return NULL; + return suffixes + i; + } + } + return NULL; +} + +static struct command_result *param_recurrence(struct command *cmd, + const char *name, + const char *buffer, + const jsmntok_t *tok, + struct tlv_offer_recurrence + **recurrence) +{ + u32 mul; + const struct time_string *ts; + + ts = json_to_time(buffer, tok, &mul); + if (!ts) + return command_fail_badparam(cmd, name, buffer, tok, + "not a valid time"); + + *recurrence = tal(cmd, struct tlv_offer_recurrence); + (*recurrence)->time_unit = ts->unit; + (*recurrence)->period = ts->mul * mul; + return NULL; +} + +static struct command_result *param_recurrence_base(struct command *cmd, + const char *name, + const char *buffer, + const jsmntok_t *tok, + struct tlv_offer_recurrence_base **base) +{ + /* Make copy so we can manipulate it */ + jsmntok_t t = *tok; + + *base = tal(cmd, struct tlv_offer_recurrence_base); + if (json_tok_startswith(buffer, &t, "@")) { + t.start++; + (*base)->start_any_period = false; + } else + (*base)->start_any_period = true; + + if (!json_to_u64(buffer, &t, &(*base)->basetime)) + return command_fail_badparam(cmd, name, buffer, tok, + "not a valid basetime or @basetime"); + return NULL; +} + +/* -time+time[%] */ +static struct command_result *param_recurrence_paywindow(struct command *cmd, + const char *name, + const char *buffer, + const jsmntok_t *tok, + struct tlv_offer_recurrence_paywindow + **paywindow) +{ + jsmntok_t t, before, after; + + *paywindow = tal(cmd, struct tlv_offer_recurrence_paywindow); + t = *tok; + if (json_tok_endswith(buffer, &t, "%")) { + (*paywindow)->proportional_amount = true; + t.end--; + } else + (*paywindow)->proportional_amount = false; + + if (!json_tok_startswith(buffer, &t, "-")) + return command_fail_badparam(cmd, name, buffer, tok, + "expected -time+time[%]"); + t.start++; + if (!split_tok(buffer, &t, '+', &before, &after)) + return command_fail_badparam(cmd, name, buffer, tok, + "expected -time+time[%]"); + + if (!json_to_u32(buffer, &before, &(*paywindow)->seconds_before)) + return command_fail_badparam(cmd, name, buffer, &before, + "expected number of seconds"); + if (!json_to_u32(buffer, &after, &(*paywindow)->seconds_after)) + return command_fail_badparam(cmd, name, buffer, &after, + "expected number of seconds"); + return NULL; +} + +struct command_result *json_offer(struct command *cmd, + const char *buffer, + const jsmntok_t *params) +{ + const char *desc, *vendor, *label; + struct tlv_offer *offer; + struct out_req *req; + bool *single_use; + + offer = tlv_offer_new(cmd); + + if (!param(cmd, buffer, params, + p_req("amount", param_amount, offer), + p_req("description", param_escaped_string, &desc), + p_opt("label", param_escaped_string, &label), + p_opt("vendor", param_escaped_string, &vendor), + p_opt("quantity_min", param_u64, &offer->quantity_min), + p_opt("quantity_max", param_u64, &offer->quantity_max), + p_opt("absolute_expiry", param_u64, &offer->absolute_expiry), + p_opt("recurrence", param_recurrence, &offer->recurrence), + p_opt("recurrence_base", + param_recurrence_base, + &offer->recurrence_base), + p_opt("recurrence_paywindow", + param_recurrence_paywindow, + &offer->recurrence_paywindow), + p_opt("recurrence_limit", + param_number, + &offer->recurrence_limit), + p_opt_def("single_use", param_bool, &single_use, false), + /* FIXME: hints support! */ + NULL)) + return command_param_failed(); + + /* 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")) { + offer->chains = tal_arr(offer, struct bitcoin_blkid, 1); + offer->chains[0] = chainparams->genesis_blockhash; + } + + if (!offer->recurrence) { + if (offer->recurrence_limit) + return command_fail_badparam(cmd, "recurrence_limit", + buffer, params, + "needs recurrence"); + if (offer->recurrence_base) + return command_fail_badparam(cmd, "recurrence_base", + buffer, params, + "needs recurrence"); + if (offer->recurrence_paywindow) + return command_fail_badparam(cmd, "recurrence_paywindow", + buffer, params, + "needs recurrence"); + } + + offer->description = tal_dup_arr(offer, char, desc, strlen(desc), 0); + if (vendor) { + offer->vendor + = tal_dup_arr(offer, char, vendor, strlen(vendor), 0); + } + + offer->node_id = tal_dup(offer, struct pubkey32, &id); + + /* We simply pass this through. */ + req = jsonrpc_request_start(cmd->plugin, cmd, "createoffer", + forward_result, forward_error, + offer); + json_add_string(req->js, "bolt12", offer_encode(tmpctx, offer)); + if (label) + json_add_string(req->js, "label", label); + json_add_bool(req->js, "single_use", *single_use); + + return send_outreq(cmd->plugin, req); +} + diff --git a/plugins/offers_offer.h b/plugins/offers_offer.h new file mode 100644 index 000000000..ccc3a34ab --- /dev/null +++ b/plugins/offers_offer.h @@ -0,0 +1,11 @@ +#ifndef LIGHTNING_PLUGINS_OFFERS_OFFER_H +#define LIGHTNING_PLUGINS_OFFERS_OFFER_H +#include "config.h" +#include + +extern struct pubkey32 id; + +struct command_result *json_offer(struct command *cmd, + const char *buffer, + const jsmntok_t *params); +#endif /* LIGHTNING_PLUGINS_OFFERS_OFFER_H */ diff --git a/tests/test_pay.py b/tests/test_pay.py index 3b1912f90..b8c8e841d 100644 --- a/tests/test_pay.py +++ b/tests/test_pay.py @@ -3686,3 +3686,159 @@ def test_mpp_overload_payee(node_factory, bitcoind): # pay. l1.rpc.pay(inv) + + +@unittest.skipIf(not EXPERIMENTAL_FEATURES, "offers are experimental") +def test_offer(node_factory, bitcoind): + l1 = node_factory.get_node() + + bolt12tool = os.path.join(os.path.dirname(__file__), "..", "devtools", "bolt12-cli") + # Try different amount strings + for amount in ['1msat', '0.1btc', 'any', '1USD', '1.10AUD']: + ret = l1.rpc.call('offer', {'amount': amount, + 'description': 'test for ' + amount}) + offer = only_one(l1.rpc.call('listoffers', [ret['offer_id']])['offers']) + + assert offer['bolt12'] == ret['bolt12'] + assert offer['offer_id'] == ret['offer_id'] + + output = subprocess.check_output([bolt12tool, 'decode', + offer['bolt12']]).decode('ASCII') + if amount == 'any': + assert 'amount' not in output + else: + assert 'amount' in output + + # Try wrong amount precision: + with pytest.raises(RpcError, match='Currency AUD requires 2 minor units'): + l1.rpc.call('offer', {'amount': '1.100AUD', + 'description': 'test for invalid amount'}) + + with pytest.raises(RpcError, match='Currency AUD requires 2 minor units'): + l1.rpc.call('offer', {'amount': '1.1AUD', + 'description': 'test for invalid amount'}) + + # Test label and description + weird_label = 'label \\ " \t \n' + weird_desc = 'description \\ " \t \n ナンセンス 1杯' + ret = l1.rpc.call('offer', {'amount': '0.1btc', + 'description': weird_desc, + 'label': weird_label}) + offer = only_one(l1.rpc.call('listoffers', [ret['offer_id']])['offers']) + assert offer['label'] == weird_label + + output = subprocess.check_output([bolt12tool, 'decode', + offer['bolt12']]).decode('UTF-8') + assert 'description: ' + weird_desc in output + + # Test vendor + weird_vendor = 'description \\ " \t \n ナンセンス 1杯' + ret = l1.rpc.call('offer', {'amount': '100000sat', + 'description': 'vendor test', + 'vendor': weird_vendor}) + offer = only_one(l1.rpc.call('listoffers', [ret['offer_id']])['offers']) + + output = subprocess.check_output([bolt12tool, 'decode', + offer['bolt12']]).decode('UTF-8') + assert 'vendor: ' + weird_vendor in output + + # Test quantity min/max + ret = l1.rpc.call('offer', {'amount': '100000sat', + 'description': 'quantity_min test', + 'quantity_min': 1}) + offer = only_one(l1.rpc.call('listoffers', [ret['offer_id']])['offers']) + output = subprocess.check_output([bolt12tool, 'decode', + offer['bolt12']]).decode('UTF-8') + assert 'quantity_min: 1' in output + + ret = l1.rpc.call('offer', {'amount': '100000sat', + 'description': 'quantity_max test', + 'quantity_max': 2}) + offer = only_one(l1.rpc.call('listoffers', [ret['offer_id']])['offers']) + output = subprocess.check_output([bolt12tool, 'decode', + offer['bolt12']]).decode('UTF-8') + assert 'quantity_max: 2' in output + + # Test absolute_expiry + exp = int(time.time() + 2) + ret = l1.rpc.call('offer', {'amount': '100000sat', + 'description': 'quantity_max test', + 'absolute_expiry': exp}) + offer = only_one(l1.rpc.call('listoffers', [ret['offer_id']])['offers']) + output = subprocess.check_output([bolt12tool, 'decode', + offer['bolt12']]).decode('UTF-8') + assert 'absolute_expiry: {}'.format(exp) in output + + # Recurrence tests! + for r in [['1second', 'seconds', 1], + ['10seconds', 'seconds', 10], + ['1minute', 'seconds', 60], + ['10minutes', 'seconds', 600], + ['1hour', 'seconds', 3600], + ['10hours', 'seconds', 36000], + ['1day', 'days', 1], + ['10days', 'days', 10], + ['1week', 'days', 7], + ['10weeks', 'days', 70], + ['1month', 'months', 1], + ['10months', 'months', 10], + ['1year', 'years', 1], + ['10years', 'years', 10]]: + ret = l1.rpc.call('offer', {'amount': '100000sat', + 'description': 'quantity_max test', + 'recurrence': r[0]}) + offer = only_one(l1.rpc.call('listoffers', [ret['offer_id']])['offers']) + output = subprocess.check_output([bolt12tool, 'decode', + offer['bolt12']]).decode('UTF-8') + assert 'recurrence: every {} {}\n'.format(r[2], r[1]) in output + + # Test limit + ret = l1.rpc.call('offer', {'amount': '100000sat', + 'description': 'quantity_max test', + 'recurrence': '10minutes', + 'recurrence_limit': 5}) + offer = only_one(l1.rpc.call('listoffers', [ret['offer_id']])['offers']) + output = subprocess.check_output([bolt12tool, 'decode', + offer['bolt12']]).decode('UTF-8') + assert 'recurrence: every 600 seconds limit 5\n' in output + + # Test base + # (1456740000 == 10:00:00 (am) UTC on 29 February, 2016) + ret = l1.rpc.call('offer', {'amount': '100000sat', + 'description': 'quantity_max test', + 'recurrence': '10minutes', + 'recurrence_base': '@1456740000'}) + offer = only_one(l1.rpc.call('listoffers', [ret['offer_id']])['offers']) + output = subprocess.check_output([bolt12tool, 'decode', + offer['bolt12']]).decode('UTF-8') + assert 'recurrence: every 600 seconds start 1456740000' in output + assert '(can start any period)' not in output + + ret = l1.rpc.call('offer', {'amount': '100000sat', + 'description': 'quantity_max test', + 'recurrence': '10minutes', + 'recurrence_base': 1456740000}) + offer = only_one(l1.rpc.call('listoffers', [ret['offer_id']])['offers']) + output = subprocess.check_output([bolt12tool, 'decode', + offer['bolt12']]).decode('UTF-8') + assert 'recurrence: every 600 seconds start 1456740000' in output + assert '(can start any period)' in output + + # Test paywindow + ret = l1.rpc.call('offer', {'amount': '100000sat', + 'description': 'quantity_max test', + 'recurrence': '10minutes', + 'recurrence_paywindow': '-10+20'}) + offer = only_one(l1.rpc.call('listoffers', [ret['offer_id']])['offers']) + output = subprocess.check_output([bolt12tool, 'decode', + offer['bolt12']]).decode('UTF-8') + assert 'recurrence: every 600 seconds paywindow -10 to +20\n' in output + + ret = l1.rpc.call('offer', {'amount': '100000sat', + 'description': 'quantity_max test', + 'recurrence': '10minutes', + 'recurrence_paywindow': '-10+600%'}) + offer = only_one(l1.rpc.call('listoffers', [ret['offer_id']])['offers']) + output = subprocess.check_output([bolt12tool, 'decode', + offer['bolt12']]).decode('UTF-8') + assert 'recurrence: every 600 seconds paywindow -10 to +600 (pay proportional)\n' in output