JSON: offers plugin offer command.

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
This commit is contained in:
Rusty Russell 2020-12-16 13:43:28 +10:30 committed by Christian Decker
parent 26e0cbeb7c
commit 08e110b568
5 changed files with 475 additions and 10 deletions

View file

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

View file

@ -1,14 +1,9 @@
/* This plugin covers both sending and receiving offers */
#include <bitcoin/chainparams.h>
#include <bitcoin/preimage.h>
#include <ccan/array_size/array_size.h>
#include <common/bolt12.h>
#include <common/bolt12_merkle.h>
#include <common/json_stream.h>
#include <common/overflows.h>
#include <common/type_to_string.h>
#include <plugins/libplugin.h>
#include <wire/onion_wire.h>
#include <plugins/offers_offer.h>
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[])

282
plugins/offers_offer.c Normal file
View file

@ -0,0 +1,282 @@
#include <bitcoin/chainparams.h>
#include <ccan/array_size/array_size.h>
#include <common/bolt12.h>
#include <common/iso4217.h>
#include <common/json_stream.h>
#include <common/overflows.h>
#include <plugins/offers_offer.h>
#include <wire/onion_wire.h>
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 <amount>[.<amount>]<ISO-4712>");
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, &cents))
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 <ISO-4712><amount>[.<amount>]");
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);
}

11
plugins/offers_offer.h Normal file
View file

@ -0,0 +1,11 @@
#ifndef LIGHTNING_PLUGINS_OFFERS_OFFER_H
#define LIGHTNING_PLUGINS_OFFERS_OFFER_H
#include "config.h"
#include <plugins/libplugin.h>
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 */

View file

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