decode: support decoding runes.

This is a bit weird since it lives in the offers plugin, but it works
well.  This should make runes much more approachable for people!

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
This commit is contained in:
Rusty Russell 2022-07-16 22:48:27 +09:30
parent 468dff1723
commit 8c48eda8c7
5 changed files with 354 additions and 9 deletions

View file

@ -120,6 +120,56 @@ time in seconds:
"unique_id": "3"
}
You can also use lightning-decode(7) to examine runes you have been given:
$ .lightning-cli decode tU-RLjMiDpY2U0o3W1oFowar36RFGpWloPbW9-RuZdo9MyZpZD0wMjRiOWExZmE4ZTAwNmYxZTM5MzdmNjVmNjZjNDA4ZTZkYThlMWNhNzI4ZWE0MzIyMmE3MzgxZGYxY2M0NDk2MDUmbWV0aG9kPWxpc3RwZWVycyZwbnVtPTEmcG5hbWVpZF4wMjRiOWExZmE4ZTAwNmYxZTM5M3xwYXJyMF4wMjRiOWExZmE4ZTAwNmYxZTM5MyZ0aW1lPDE2NTY5MjA1MzgmcmF0ZT0y
{
"type": "rune",
"unique_id": "3",
"string": "b54f912e33220e9636534a375b5a05a306abdfa4451a95a5a0f6d6f7e46e65da:=3&id=024b9a1fa8e006f1e3937f65f66c408e6da8e1ca728ea43222a7381df1cc449605&method=listpeers&pnum=1&pnameid^024b9a1fa8e006f1e393|parr0^024b9a1fa8e006f1e393&time<1656920538&rate=2",
"restrictions": [
{
"alternatives": [
"id=024b9a1fa8e006f1e3937f65f66c408e6da8e1ca728ea43222a7381df1cc449605"
],
"summary": "id (of commanding peer) equal to '024b9a1fa8e006f1e3937f65f66c408e6da8e1ca728ea43222a7381df1cc449605'"
},
{
"alternatives": [
"method=listpeers"
],
"summary": "method (of command) equal to 'listpeers'"
},
{
"alternatives": [
"pnum=1"
],
"summary": "pnum (number of command parameters) equal to 1"
},
{
"alternatives": [
"pnameid^024b9a1fa8e006f1e393",
"parr0^024b9a1fa8e006f1e393"
],
"summary": "pnameid (object parameter 'id') starts with '024b9a1fa8e006f1e393' OR parr0 (array parameter #0) starts with '024b9a1fa8e006f1e393'"
},
{
"alternatives": [
"time<1656920538"
],
"summary": "time (in seconds since 1970) less than 1656920538 (approximately 19 hours 18 minutes from now)"
},
{
"alternatives": [
"rate=2"
],
"summary": "rate (max per minute) equal to 2"
}
],
"valid": true
}
SHARING RUNES
-------------
@ -157,7 +207,7 @@ excuses his previous adoption of the name "Eltoo".
SEE ALSO
--------
lightning-commando(7)
lightning-commando(7), lightning-decode(7)
RESOURCES
---------

View file

@ -9,17 +9,21 @@ SYNOPSIS
DESCRIPTION
-----------
The **decode** RPC command checks and parses a *bolt11* or *bolt12*
string (optionally prefixed by `lightning:` or `LIGHTNING:`) as
specified by the BOLT 11 and BOLT 12 specifications. It may decode
other formats in future.
The **decode** RPC command checks and parses:
- a *bolt11* or *bolt12* string (optionally prefixed by `lightning:`
or `LIGHTNING:`) as specified by the BOLT 11 and BOLT 12
specifications.
- a *rune* as created by lightning-commando-rune(7).
It may decode other formats in future.
RETURN VALUE
------------
[comment]: # (GENERATE-FROM-SCHEMA-START)
On success, an object is returned, containing:
- **type** (string): what kind of object it decoded to (one of "bolt12 offer", "bolt12 invoice", "bolt12 invoice_request", "bolt11 invoice")
- **type** (string): what kind of object it decoded to (one of "bolt12 offer", "bolt12 invoice", "bolt12 invoice_request", "bolt11 invoice", "rune")
- **valid** (boolean): if this is false, you *MUST* not use the result except for diagnostics!
If **type** is "bolt12 offer", and **valid** is *true*:
@ -159,6 +163,16 @@ If **type** is "bolt11 invoice", and **valid** is *true*:
- **tag** (string): The bech32 letter which identifies this field (always 1 characters)
- **data** (string): The bech32 data for this field
If **type** is "rune":
- **string** (string): the string encoding of the rune
- **restrictions** (array of objects): restrictions built into the rune: all must pass:
- **alternatives** (array of strings): each way restriction can be met: any can pass:
- the alternative of form fieldname condition fieldname
- **summary** (string): human-readable summary of this restriction
- **unique_id** (string, optional): unique id (always a numeric id on runes we create)
- **version** (string, optional): rune version, not currently set on runes we create
- **valid** (boolean, optional) (always *true*)
[comment]: # (GENERATE-FROM-SCHEMA-END)
AUTHOR
@ -169,7 +183,7 @@ Rusty Russell <<rusty@rustcorp.com.au>> is mainly responsible.
SEE ALSO
--------
lightning-pay(7), lightning-offer(7), lightning-offerout(7), lightning-fetchinvoice(7), lightning-sendinvoice(7)
lightning-pay(7), lightning-offer(7), lightning-offerout(7), lightning-fetchinvoice(7), lightning-sendinvoice(7), lightning-commando-rune(7)
[BOLT \#11](https://github.com/lightningnetwork/lightning-rfc/blob/master/11-payment-encoding.md).
@ -181,4 +195,4 @@ RESOURCES
Main web site: <https://github.com/ElementsProject/lightning>
[comment]: # ( SHA256STAMP:bc3778965137591623ce08ff51adf411bc42e6d1a4200692961b69962da39be7)
[comment]: # ( SHA256STAMP:d1e1f044c2e67ec169728dbc551903c97f9a9daa1f42e9d2f1686fc692d25be8)

View file

@ -12,7 +12,8 @@
"bolt12 offer",
"bolt12 invoice",
"bolt12 invoice_request",
"bolt11 invoice"
"bolt11 invoice",
"rune"
],
"description": "what kind of object it decoded to"
},
@ -909,6 +910,72 @@
}
}
}
},
{
"if": {
"properties": {
"type": {
"type": "string",
"enum": [
"rune"
]
}
}
},
"then": {
"required": [
"string",
"restrictions"
],
"additionalProperties": false,
"properties": {
"unique_id": {
"type": "string",
"description": "unique id (always a numeric id on runes we create)"
},
"version": {
"type": "string",
"description": "rune version, not currently set on runes we create"
},
"valid": {
"type": "boolean",
"enum": [
true
]
},
"type": {},
"string": {
"type": "string",
"description": "the string encoding of the rune"
},
"restrictions": {
"type": "array",
"description": "restrictions built into the rune: all must pass",
"items": {
"type": "object",
"required": [
"alternatives",
"summary"
],
"additionalProperties": false,
"properties": {
"alternatives": {
"type": "array",
"description": "each way restriction can be met: any can pass",
"items": {
"type": "string",
"description": "the alternative of form fieldname condition fieldname"
}
},
"summary": {
"type": "string",
"description": "human-readable summary of this restriction"
}
}
}
}
}
}
}
]
}

View file

@ -3,6 +3,7 @@
#include <bitcoin/chainparams.h>
#include <ccan/array_size/array_size.h>
#include <ccan/cast/cast.h>
#include <ccan/rune/rune.h>
#include <ccan/tal/str/str.h>
#include <common/bech32.h>
#include <common/bolt11.h>
@ -204,6 +205,7 @@ struct decodable {
struct tlv_offer *offer;
struct tlv_invoice *invoice;
struct tlv_invoice_request *invreq;
struct rune *rune;
};
static struct command_result *param_decodable(struct command *cmd,
@ -271,6 +273,13 @@ static struct command_result *param_decodable(struct command *cmd,
return NULL;
}
decodable->rune = rune_from_base64n(decodable, buffer + tok.start,
tok.end - tok.start);
if (decodable->rune) {
decodable->type = "rune";
return NULL;
}
/* Return failure message from most likely parsing candidate */
return command_fail_badparam(cmd, name, buffer, &tok, likely_fail);
}
@ -808,6 +817,156 @@ static void json_add_invoice_request(struct json_stream *js,
json_add_bool(js, "valid", valid);
}
static void json_add_rune(struct command *cmd, struct json_stream *js, const struct rune *rune)
{
if (rune->unique_id)
json_add_string(js, "unique_id", rune->unique_id);
if (rune->version)
json_add_string(js, "version", rune->version);
json_add_string(js, "string", take(rune_to_string(NULL, rune)));
json_array_start(js, "restrictions");
for (size_t i = rune->unique_id ? 1 : 0; i < tal_count(rune->restrs); i++) {
const struct rune_restr *restr = rune->restrs[i];
char *summary = tal_strdup(tmpctx, "");
const char *sep = "";
json_object_start(js, NULL);
json_array_start(js, "alternatives");
for (size_t j = 0; j < tal_count(restr->alterns); j++) {
const struct rune_altern *alt = restr->alterns[j];
const char *annotation, *value;
bool int_val = false, time_val = false;
if (streq(alt->fieldname, "time")) {
annotation = "in seconds since 1970";
time_val = true;
} else if (streq(alt->fieldname, "id"))
annotation = "of commanding peer";
else if (streq(alt->fieldname, "method"))
annotation = "of command";
else if (streq(alt->fieldname, "pnum")) {
annotation = "number of command parameters";
int_val = true;
} else if (streq(alt->fieldname, "rate")) {
annotation = "max per minute";
int_val = true;
} else if (strstarts(alt->fieldname, "parr")) {
annotation = tal_fmt(tmpctx, "array parameter #%s", alt->fieldname+4);
} else if (strstarts(alt->fieldname, "pname"))
annotation = tal_fmt(tmpctx, "object parameter '%s'", alt->fieldname+5);
else
annotation = "unknown condition?";
tal_append_fmt(&summary, "%s", sep);
/* Where it's ambiguous, quote if it's not treated as an int */
if (int_val)
value = alt->value;
else if (time_val) {
u64 t = atol(alt->value);
if (t) {
u64 diff, now = time_now().ts.tv_sec;
/* Need a non-const during construction */
char *v;
if (now > t)
diff = now - t;
else
diff = t - now;
if (diff < 60)
v = tal_fmt(tmpctx, "%"PRIu64" seconds", diff);
else if (diff < 60 * 60)
v = tal_fmt(tmpctx, "%"PRIu64" minutes %"PRIu64" seconds",
diff / 60, diff % 60);
else {
v = tal_strdup(tmpctx, "approximately ");
/* diff is in minutes */
diff /= 60;
if (diff < 48 * 60)
tal_append_fmt(&v, "%"PRIu64" hours %"PRIu64" minutes",
diff / 60, diff % 60);
else {
/* hours */
diff /= 60;
if (diff < 60 * 24)
tal_append_fmt(&v, "%"PRIu64" days %"PRIu64" hours",
diff / 24, diff % 24);
else {
/* days */
diff /= 24;
if (diff < 365 * 2)
tal_append_fmt(&v, "%"PRIu64" months %"PRIu64" days",
diff / 30, diff % 30);
else {
/* months */
diff /= 30;
tal_append_fmt(&v, "%"PRIu64" years %"PRIu64" months",
diff / 12, diff % 12);
}
}
}
}
if (now > t)
tal_append_fmt(&v, " ago");
else
tal_append_fmt(&v, " from now");
value = tal_fmt(tmpctx, "%s (%s)", alt->value, v);
} else
value = alt->value;
} else
value = tal_fmt(tmpctx, "'%s'", alt->value);
switch (alt->condition) {
case RUNE_COND_IF_MISSING:
tal_append_fmt(&summary, "%s (%s) is missing", alt->fieldname, annotation);
break;
case RUNE_COND_EQUAL:
tal_append_fmt(&summary, "%s (%s) equal to %s", alt->fieldname, annotation, value);
break;
case RUNE_COND_NOT_EQUAL:
tal_append_fmt(&summary, "%s (%s) unequal to %s", alt->fieldname, annotation, value);
break;
case RUNE_COND_BEGINS:
tal_append_fmt(&summary, "%s (%s) starts with '%s'", alt->fieldname, annotation, alt->value);
break;
case RUNE_COND_ENDS:
tal_append_fmt(&summary, "%s (%s) ends with '%s'", alt->fieldname, annotation, alt->value);
break;
case RUNE_COND_CONTAINS:
tal_append_fmt(&summary, "%s (%s) contains '%s'", alt->fieldname, annotation, alt->value);
break;
case RUNE_COND_INT_LESS:
tal_append_fmt(&summary, "%s (%s) less than %s", alt->fieldname, annotation,
time_val ? value : alt->value);
break;
case RUNE_COND_INT_GREATER:
tal_append_fmt(&summary, "%s (%s) greater than %s", alt->fieldname, annotation,
time_val ? value : alt->value);
break;
case RUNE_COND_LEXO_BEFORE:
tal_append_fmt(&summary, "%s (%s) sorts before '%s'", alt->fieldname, annotation, alt->value);
break;
case RUNE_COND_LEXO_AFTER:
tal_append_fmt(&summary, "%s (%s) sorts after '%s'", alt->fieldname, annotation, alt->value);
break;
case RUNE_COND_COMMENT:
tal_append_fmt(&summary, "[comment: %s%s]", alt->fieldname, alt->value);
break;
}
sep = " OR ";
json_add_str_fmt(js, NULL, "%s%c%s", alt->fieldname, alt->condition, alt->value);
}
json_array_end(js);
json_add_string(js, "summary", summary);
json_object_end(js);
}
json_array_end(js);
/* FIXME: do some sanity checks? */
json_add_bool(js, "valid", true);
}
static struct command_result *json_decode(struct command *cmd,
const char *buffer,
const jsmntok_t *params)
@ -833,6 +992,8 @@ static struct command_result *json_decode(struct command *cmd,
json_add_bolt11(response, decodable->b11);
json_add_bool(response, "valid", true);
}
if (decodable->rune)
json_add_rune(cmd, response, decodable->rune);
return command_finished(cmd, response);
}

View file

@ -2673,6 +2673,59 @@ def test_commando_rune(node_factory):
assert rune9['rune'] == 'O8Zr-ULTBKO3_pKYz0QKE9xYl1vQ4Xx9PtlHuist9Rk9NCZwbnVtPTAmcmF0ZT0zJnJhdGU9MQ=='
assert rune9['unique_id'] == '4'
runedecodes = ((rune1, []),
(rune2, [{'alternatives': ['method^list', 'method^get', 'method=summary'],
'summary': "method (of command) starts with 'list' OR method (of command) starts with 'get' OR method (of command) equal to 'summary'"},
{'alternatives': ['method/listdatastore'],
'summary': "method (of command) unequal to 'listdatastore'"}]),
(rune4, [{'alternatives': ['id^022d223620a359a47ff7'],
'summary': "id (of commanding peer) starts with '022d223620a359a47ff7'"},
{'alternatives': ['method=listpeers'],
'summary': "method (of command) equal to 'listpeers'"}]),
(rune5, [{'alternatives': ['id^022d223620a359a47ff7'],
'summary': "id (of commanding peer) starts with '022d223620a359a47ff7'"},
{'alternatives': ['method=listpeers'],
'summary': "method (of command) equal to 'listpeers'"},
{'alternatives': ['pnamelevel!', 'pnamelevel/io'],
'summary': "pnamelevel (object parameter 'level') is missing OR pnamelevel (object parameter 'level') unequal to 'io'"}]),
(rune6, [{'alternatives': ['id^022d223620a359a47ff7'],
'summary': "id (of commanding peer) starts with '022d223620a359a47ff7'"},
{'alternatives': ['method=listpeers'],
'summary': "method (of command) equal to 'listpeers'"},
{'alternatives': ['pnamelevel!', 'pnamelevel/io'],
'summary': "pnamelevel (object parameter 'level') is missing OR pnamelevel (object parameter 'level') unequal to 'io'"},
{'alternatives': ['parr1!', 'parr1/io'],
'summary': "parr1 (array parameter #1) is missing OR parr1 (array parameter #1) unequal to 'io'"}]),
(rune7, [{'alternatives': ['pnum=0'],
'summary': "pnum (number of command parameters) equal to 0"}]),
(rune8, [{'alternatives': ['pnum=0'],
'summary': "pnum (number of command parameters) equal to 0"},
{'alternatives': ['rate=3'],
'summary': "rate (max per minute) equal to 3"}]),
(rune9, [{'alternatives': ['pnum=0'],
'summary': "pnum (number of command parameters) equal to 0"},
{'alternatives': ['rate=3'],
'summary': "rate (max per minute) equal to 3"},
{'alternatives': ['rate=1'],
'summary': "rate (max per minute) equal to 1"}]))
for decode in runedecodes:
rune = decode[0]
restrictions = decode[1]
decoded = l1.rpc.decode(rune['rune'])
assert decoded['type'] == 'rune'
assert decoded['unique_id'] == rune['unique_id']
assert decoded['valid'] is True
assert decoded['restrictions'] == restrictions
# Time handling is a bit special, since we annotate the timestamp with how far away it is.
decoded = l1.rpc.decode(rune3['rune'])
assert decoded['type'] == 'rune'
assert decoded['unique_id'] == rune3['unique_id']
assert decoded['valid'] is True
assert len(decoded['restrictions']) == 1
assert decoded['restrictions'][0]['alternatives'] == ['time>1656675211']
assert decoded['restrictions'][0]['summary'].startswith("time (in seconds since 1970) greater than 1656675211 (")
# Replace rune3 with a more useful timestamp!
expiry = int(time.time()) + 15
rune3 = l1.rpc.commando_rune(restrictions="time<{}".format(expiry))