diff --git a/doc/lightning-commando-rune.7.md b/doc/lightning-commando-rune.7.md index 97bc4ec9a..c2285b650 100644 --- a/doc/lightning-commando-rune.7.md +++ b/doc/lightning-commando-rune.7.md @@ -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 --------- diff --git a/doc/lightning-decode.7.md b/doc/lightning-decode.7.md index 7c916562b..01b3a3c6e 100644 --- a/doc/lightning-decode.7.md +++ b/doc/lightning-decode.7.md @@ -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 <> 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: -[comment]: # ( SHA256STAMP:bc3778965137591623ce08ff51adf411bc42e6d1a4200692961b69962da39be7) +[comment]: # ( SHA256STAMP:d1e1f044c2e67ec169728dbc551903c97f9a9daa1f42e9d2f1686fc692d25be8) diff --git a/doc/schemas/decode.schema.json b/doc/schemas/decode.schema.json index 2c84e820b..1ff631d28 100644 --- a/doc/schemas/decode.schema.json +++ b/doc/schemas/decode.schema.json @@ -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" + } + } + } + } + } + } } ] } diff --git a/plugins/offers.c b/plugins/offers.c index 5ca4ea4d7..72442234c 100644 --- a/plugins/offers.c +++ b/plugins/offers.c @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -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); } diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 9061ac715..3d7a76ca0 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -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))