What changed, and why it matters
This commit adds a new experimental JSON-RPC command called `createproof` to Core Lightning. It lets a user generate a cryptographically signed receipt (a 'payer proof') showing they successfully paid a BOLT12 invoice or offer. The change is a feature addition, not a fix for a known vulnerability. It exposes a new RPC that signs proof data using the node's keys, so the main security consideration is making sure only authorized callers can use it and that the proof data is signed correctly. The commit itself does not claim to fix any security issue, and there is no evidence of malicious intent or a disclosed vulnerability.
Review the new `createproof` RPC for proper RPC authorization (runes/permissions), ensure input validation on `invstring` and `include` is robust, verify that `payersign` cannot be tricked into signing attacker-chosen merkle roots, and audit memory handling in the new plugin code. Because this is an experimental feature, consider restricting access until the BOLT12 proof specification is finalized.
Security signals we found
New experimental RPC added (`createproof`) that signs payment proofs with node keys via `payersign`
Existing internal `merkle_payer_proof` renamed and exported as `bolt12_payer_proof_merkle` so the plugin can compute the signing merkle root
Proof construction enforces mandatory fields (invreq_payer_id, invoice_payment_hash, invoice_features, invoice_node_id, signature) and allows caller-controlled optional `include` list
RPC requires a successful completed payment (`listsendpays` status=complete) and returns error 1700 if none found
No security relevance, vulnerability fix, or incident disclosure is stated in the commit message or diff
Evidence from the diff
The patch introduces createproof, an experimental RPC that constructs a BOLT12 payer_proof TLV for completed payments. It adds plugins/offers_proof.c/h, exposes bolt12_payer_proof_merkle() from common/bolt12_proof.c for external signing, registers error code CREATEPROOF_NO_PAYMENT (1700), and adds schema/docs/tests. The plugin queries listsendpays for completed payments matching an invoice, offer, or BIP353 name, builds an unsigned proof via make_unsigned_proof(), computes the merkle root, requests a BIP340 signature through payersign, and returns encoded lnp1... proofs. Security signals are limited to normal RPC authorization and correct signature handling; no vulnerability or incident is described.
Changed components
plugins/offers.cplugins/offers_proof.cplugins/offers_proof.hcommon/bolt12_proof.ccommon/bolt12_proof.hcommon/jsonrpc_errors.hdoc/schemas/createproof.jsontests/test_pay.pyInspect captured patch +985 / −6
diff --git a/common/bolt12_proof.c b/common/bolt12_proof.c
index f8bbe5a3..6276a233 100644
--- a/common/bolt12_proof.c
+++ b/common/bolt12_proof.c
@@ -188,13 +188,13 @@ static const struct tlv_field *next_field_prepend_tlv0(bool *is_omitted,
/* BOLT-payer_proof #12:
* - MUST set `proof_signature` as detailed in [Signature Calculation](#signature-calculation) using the `invreq_payer_id` using the merkle-root as the `msg` and a `first_tlv` value of 0x0000 (i.e. type 0, length 0).
*/
-static void merkle_payer_proof(const struct tlv_field *fields,
+void bolt12_payer_proof_merkle(const struct tlv_payer_proof *proof,
struct sha256 *merkle)
{
struct tlv0_adding_leaf_iter iter;
/* We use a modified iterator to insert tlv0. */
- iter.fields = fields;
+ iter.fields = proof->fields;
iter.n = -1;
iter.tlv0.meta = NULL;
iter.tlv0.numtype = 0;
@@ -220,7 +220,7 @@ struct bip340sig *payer_proof_signature_(const tal_t *ctx,
struct sha256 merkle;
struct bip340sig *sig;
- merkle_payer_proof(unsignedproof->fields, &merkle);
+ bolt12_payer_proof_merkle(unsignedproof, &merkle);
sig = tal(ctx, struct bip340sig);
if (!sign("payer_proof", "proof_signature", &merkle, sig, arg))
@@ -482,7 +482,7 @@ const char *check_payer_proof(const tal_t *ctx,
* Calculation](#signature-calculation), using `msg` merkle-root and
* a `first_tlv` value of 0x0000 (i.e. type 0, length 0).
*/
- merkle_payer_proof(pptlv->fields, &merkle);
+ bolt12_payer_proof_merkle(pptlv, &merkle);
sighash_from_merkle("payer_proof", "proof_signature", &merkle, &shash);
if (!check_schnorr_sig(&shash, &pptlv->invreq_payer_id->pubkey,
pptlv->proof_signature)) {
diff --git a/common/bolt12_proof.h b/common/bolt12_proof.h
index bfd8cdf6..0193cc43 100644
--- a/common/bolt12_proof.h
+++ b/common/bolt12_proof.h
@@ -74,6 +74,11 @@ struct bip340sig *payer_proof_signature_(const tal_t *ctx,
struct bip340sig *sig,
void *arg),
void *arg);
+/**
+ * bolt12_payer_proof_merkle - get the merkle root of this proof for signing.
+ */
+void bolt12_payer_proof_merkle(const struct tlv_payer_proof *proof,
+ struct sha256 *merkle);
/* Check the payer proof signatures are valid: returns NULL if so,
* otherwise error string */
diff --git a/common/jsonrpc_errors.h b/common/jsonrpc_errors.h
index a2ea1bf1..4108701e 100644
--- a/common/jsonrpc_errors.h
+++ b/common/jsonrpc_errors.h
@@ -142,6 +142,9 @@ enum jsonrpc_errcode {
/* Errors from recover command */
RECOVER_NODE_IN_USE = 1600,
+ /* Errors from createproof */
+ CREATEPROOF_NO_PAYMENT = 1700,
+
/* Errors from wait* commands */
WAIT_TIMEOUT = 2000,
};
diff --git a/contrib/msggen/msggen/schema.json b/contrib/msggen/msggen/schema.json
index 6577df27..e79cb442 100644
--- a/contrib/msggen/msggen/schema.json
+++ b/contrib/msggen/msggen/schema.json
@@ -6964,6 +6964,170 @@
}
]
},
+ "createproof.json": {
+ "$schema": "../rpc-schema-draft.json",
+ "type": "object",
+ "rpc": "createproof",
+ "added": "v26.06",
+ "title": "Create a BOLT12 payer proof (EXPERIMENTAL)",
+ "description": [
+ "NOTE: The proof format is still a draft: IT MAY CHANGE IN INCOMPATIBLE WAYS IN FUTURE.",
+ "",
+ "The **createproof** RPC command creates a proofs that we have made a BOLT12 payment according to the current draft specification.",
+ "In case we have made multiple payments to the same thing, we will create multiple proofs.",
+ "Each returned proof includes enough fields from the offer and invoice itself that a reader can verify it: see `decode`."
+ ],
+ "request": {
+ "required": [
+ "invstring"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "invstring": {
+ "type": "string",
+ "description": [
+ "bolt12 invoice, a bolt12 (non-recursive) offer or a BIP353 name."
+ ]
+ },
+ "note": {
+ "type": "string",
+ "description": [
+ "A note to include in the proof: someone can challenge you to create a proof with a given note, and so you can demonstrate that you can indeed make a new proof. Default is an empty string."
+ ]
+ },
+ "include": {
+ "type": "array",
+ "description": [
+ "An explicit list of TLV field names or numbers to include in the proof if they are present. The default values are:",
+ "* 10 (offer_description): identifies what was purchased",
+ "* 22 (offer_issuer_id): identifies who issued the offer being proved",
+ "* 164 (invoice_created_at): timestamp of invoice",
+ "* 170 (invoice_amount): the amount actually paid",
+ "",
+ "Note that the following fields are always included: 88 (invreq_payer_id), 168 (invoice_payment_hash), 174 (invoice_features), 176 (invoice_node_id) and 240 (signature)."
+ ],
+ "items": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": [
+ "Name of an offer, invoice_request or invoice field to include"
+ ]
+ },
+ {
+ "type": "u64",
+ "description": [
+ "Number of an offer, invoice_request or invoice field to include (allows reference to unknown fields)"
+ ]
+ }
+ ]
+ }
+ }
+ }
+ },
+ "response": {
+ "required": [
+ "proofs"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "proofs": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "bolt12",
+ "offer_fields_included",
+ "invreq_fields_included",
+ "invoice_fields_included"
+ ],
+ "properties": {
+ "bolt12": {
+ "type": "string",
+ "description": [
+ "The payer_proof."
+ ]
+ },
+ "offer_fields_included": {
+ "type": "array",
+ "items": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": [
+ "The name of the field from the offer we included in the proof."
+ ]
+ },
+ {
+ "type": "u64",
+ "description": [
+ "The number of the field from the offer we included in the proof (if we don't know the name)."
+ ]
+ }
+ ]
+ }
+ },
+ "invreq_fields_included": {
+ "type": "array",
+ "items": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": [
+ "The name of the field from the invoice_request we included in the proof."
+ ]
+ },
+ {
+ "type": "u64",
+ "description": [
+ "The number of the field from the invoice_request we included in the proof (if we don't know the name)."
+ ]
+ }
+ ]
+ }
+ },
+ "invoice_fields_included": {
+ "type": "array",
+ "items": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": [
+ "The name of the field from the invoice we included in the proof."
+ ]
+ },
+ {
+ "type": "u64",
+ "description": [
+ "The number of the field from the invoice we included in the proof (if we don't know the name)."
+ ]
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "errors": [
+ "The following error codes may occur:",
+ "",
+ "- -1: Catchall nonspecific error.",
+ "- 1700: We could not find a successful payment for that invstring."
+ ],
+ "author": [
+ "Rusty Russell [rusty@rustcorp.com.au](mailto:rusty@rustcorp.com.au) is mainly responsible."
+ ],
+ "see_also": [
+ "lightning-xpay(7)",
+ "lightning-decode(7)"
+ ],
+ "resources": [
+ "Main web site: [https://github.com/ElementsProject/lightning](https://github.com/ElementsProject/lightning)"
+ ]
+ },
"createrune.json": {
"$schema": "../rpc-schema-draft.json",
"type": "object",
diff --git a/doc/Makefile b/doc/Makefile
index c430de68..81e3fc47 100644
--- a/doc/Makefile
+++ b/doc/Makefile
@@ -42,6 +42,7 @@ MARKDOWNPAGES := doc/addgossip.7 \
doc/connect.7 \
doc/createinvoice.7 \
doc/createonion.7 \
+ doc/createproof.7 \
doc/createrune.7 \
doc/currencyconvert.7 \
doc/currencyrate.7 \
diff --git a/doc/index.rst b/doc/index.rst
index 6ecc0a94..96c3443e 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -50,6 +50,7 @@ Core Lightning Documentation
connect <connect.7.md>
createinvoice <createinvoice.7.md>
createonion <createonion.7.md>
+ createproof <createproof.7.md>
createrune <createrune.7.md>
currencyconvert <currencyconvert.7.md>
currencyrate <currencyrate.7.md>
diff --git a/doc/schemas/createproof.json b/doc/schemas/createproof.json
new file mode 100644
index 00000000..2579ee0c
--- /dev/null
+++ b/doc/schemas/createproof.json
@@ -0,0 +1,164 @@
+{
+ "$schema": "../rpc-schema-draft.json",
+ "type": "object",
+ "rpc": "createproof",
+ "added": "v26.06",
+ "title": "Create a BOLT12 payer proof (EXPERIMENTAL)",
+ "description": [
+ "NOTE: The proof format is still a draft: IT MAY CHANGE IN INCOMPATIBLE WAYS IN FUTURE.",
+ "",
+ "The **createproof** RPC command creates a proofs that we have made a BOLT12 payment according to the current draft specification.",
+ "In case we have made multiple payments to the same thing, we will create multiple proofs.",
+ "Each returned proof includes enough fields from the offer and invoice itself that a reader can verify it: see `decode`."
+ ],
+ "request": {
+ "required": [
+ "invstring"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "invstring": {
+ "type": "string",
+ "description": [
+ "bolt12 invoice, a bolt12 (non-recursive) offer or a BIP353 name."
+ ]
+ },
+ "note": {
+ "type": "string",
+ "description": [
+ "A note to include in the proof: someone can challenge you to create a proof with a given note, and so you can demonstrate that you can indeed make a new proof. Default is an empty string."
+ ]
+ },
+ "include": {
+ "type": "array",
+ "description": [
+ "An explicit list of TLV field names or numbers to include in the proof if they are present. The default values are:",
+ "* 10 (offer_description): identifies what was purchased",
+ "* 22 (offer_issuer_id): identifies who issued the offer being proved",
+ "* 164 (invoice_created_at): timestamp of invoice",
+ "* 170 (invoice_amount): the amount actually paid",
+ "",
+ "Note that the following fields are always included: 88 (invreq_payer_id), 168 (invoice_payment_hash), 174 (invoice_features), 176 (invoice_node_id) and 240 (signature)."
+ ],
+ "items": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": [
+ "Name of an offer, invoice_request or invoice field to include"
+ ]
+ },
+ {
+ "type": "u64",
+ "description": [
+ "Number of an offer, invoice_request or invoice field to include (allows reference to unknown fields)"
+ ]
+ }
+ ]
+ }
+ }
+ }
+ },
+ "response": {
+ "required": [
+ "proofs"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "proofs": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "bolt12",
+ "offer_fields_included",
+ "invreq_fields_included",
+ "invoice_fields_included"
+ ],
+ "properties": {
+ "bolt12": {
+ "type": "string",
+ "description": [
+ "The payer_proof."
+ ]
+ },
+ "offer_fields_included": {
+ "type": "array",
+ "items": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": [
+ "The name of the field from the offer we included in the proof."
+ ]
+ },
+ {
+ "type": "u64",
+ "description": [
+ "The number of the field from the offer we included in the proof (if we don't know the name)."
+ ]
+ }
+ ]
+ }
+ },
+ "invreq_fields_included": {
+ "type": "array",
+ "items": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": [
+ "The name of the field from the invoice_request we included in the proof."
+ ]
+ },
+ {
+ "type": "u64",
+ "description": [
+ "The number of the field from the invoice_request we included in the proof (if we don't know the name)."
+ ]
+ }
+ ]
+ }
+ },
+ "invoice_fields_included": {
+ "type": "array",
+ "items": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": [
+ "The name of the field from the invoice we included in the proof."
+ ]
+ },
+ {
+ "type": "u64",
+ "description": [
+ "The number of the field from the invoice we included in the proof (if we don't know the name)."
+ ]
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "errors": [
+ "The following error codes may occur:",
+ "",
+ "- -1: Catchall nonspecific error.",
+ "- 1700: We could not find a successful payment for that invstring."
+ ],
+ "author": [
+ "Rusty Russell [rusty@rustcorp.com.au](mailto:rusty@rustcorp.com.au) is mainly responsible."
+ ],
+ "see_also": [
+ "lightning-xpay(7)",
+ "lightning-decode(7)"
+ ],
+ "resources": [
+ "Main web site: [https://github.com/ElementsProject/lightning](https://github.com/ElementsProject/lightning)"
+ ]
+}
diff --git a/plugins/Makefile b/plugins/Makefile
index 6840018d..cc3cf0db 100644
--- a/plugins/Makefile
+++ b/plugins/Makefile
@@ -37,7 +37,7 @@ PLUGIN_PAY_LIB_HEADER := \
PLUGIN_PAY_LIB_OBJS := $(PLUGIN_PAY_LIB_SRC:.c=.o)
-PLUGIN_OFFERS_SRC := plugins/offers.c plugins/offers_offer.c plugins/offers_invreq_hook.c plugins/offers_inv_hook.c plugins/establish_onion_path.c plugins/fetchinvoice.c
+PLUGIN_OFFERS_SRC := plugins/offers.c plugins/offers_offer.c plugins/offers_invreq_hook.c plugins/offers_inv_hook.c plugins/establish_onion_path.c plugins/fetchinvoice.c plugins/offers_proof.c
PLUGIN_OFFERS_OBJS := $(PLUGIN_OFFERS_SRC:.c=.o)
PLUGIN_OFFERS_HEADER := $(PLUGIN_OFFERS_SRC:.c=.h)
diff --git a/plugins/offers.c b/plugins/offers.c
index 7384b810..2e8d6ffd 100644
--- a/plugins/offers.c
+++ b/plugins/offers.c
@@ -10,6 +10,7 @@
#include <common/bolt11_json.h>
#include <common/bolt12_id.h>
#include <common/bolt12_merkle.h>
+#include <common/bolt12_proof.h>
#include <common/clock_time.h>
#include <common/features.h>
#include <common/gossmap.h>
@@ -19,7 +20,6 @@
#include <common/json_stream.h>
#include <common/memleak.h>
#include <common/onion_message.h>
-#include <common/bolt12_proof.h>
#include <errno.h>
#include <inttypes.h>
#include <plugins/establish_onion_path.h>
@@ -28,6 +28,7 @@
#include <plugins/offers_inv_hook.h>
#include <plugins/offers_invreq_hook.h>
#include <plugins/offers_offer.h>
+#include <plugins/offers_proof.h>
#include <sodium.h>
#define HEADER_LEN crypto_secretstream_xchacha20poly1305_HEADERBYTES
@@ -1954,6 +1955,10 @@ static const struct plugin_command commands[] = {
"cancelrecurringinvoice",
json_cancelrecurringinvoice,
},
+ {
+ "createproof",
+ json_createproof,
+ },
{
"dev-rawrequest",
json_dev_rawrequest,
diff --git a/plugins/offers_proof.c b/plugins/offers_proof.c
new file mode 100644
index 00000000..2fb42cf6
--- /dev/null
+++ b/plugins/offers_proof.c
@@ -0,0 +1,506 @@
+#include "config.h"
+#include <bitcoin/preimage.h>
+#include <ccan/array_size/array_size.h>
+#include <ccan/crypto/sha256/sha256.h>
+#include <ccan/mem/mem.h>
+#include <ccan/str/hex/hex.h>
+#include <ccan/tal/str/str.h>
+#include <common/bolt11.h>
+#include <common/bolt12.h>
+#include <common/bolt12_proof.h>
+#include <common/json_param.h>
+#include <common/json_parse_simple.h>
+#include <common/json_stream.h>
+#include <common/jsonrpc_errors.h>
+#include <common/utils.h>
+#include <plugins/libplugin.h>
+#include <plugins/offers.h>
+#include <plugins/offers_proof.h>
+
+/* Default field types to include (in addition to mandatory ones). */
+static const bigsize_t default_extras[] = {
+ TLV_INVOICE_OFFER_DESCRIPTION,
+ TLV_INVOICE_OFFER_ISSUER_ID,
+ TLV_INVOICE_INVOICE_CREATED_AT,
+ TLV_INVOICE_INVOICE_AMOUNT,
+};
+
+/* Mandatory field types (always included). */
+static const bigsize_t mandatory_types[] = {
+ TLV_INVOICE_INVREQ_PAYER_ID,
+ TLV_INVOICE_INVOICE_PAYMENT_HASH,
+ TLV_INVOICE_INVOICE_FEATURES,
+ TLV_INVOICE_INVOICE_NODE_ID,
+};
+
+/* Name-to-number mapping for all known bolt12 invoice fields. */
+static const struct {
+ const char *name;
+ bigsize_t num;
+} field_names[] = {
+ { "offer_chains", TLV_INVOICE_OFFER_CHAINS },
+ { "offer_metadata", TLV_INVOICE_OFFER_METADATA },
+ { "offer_currency", TLV_INVOICE_OFFER_CURRENCY },
+ { "offer_amount", TLV_INVOICE_OFFER_AMOUNT },
+ { "offer_description", TLV_INVOICE_OFFER_DESCRIPTION },
+ { "offer_features", TLV_INVOICE_OFFER_FEATURES },
+ { "offer_absolute_expiry", TLV_INVOICE_OFFER_ABSOLUTE_EXPIRY },
+ { "offer_paths", TLV_INVOICE_OFFER_PATHS },
+ { "offer_issuer", TLV_INVOICE_OFFER_ISSUER },
+ { "offer_quantity_max", TLV_INVOICE_OFFER_QUANTITY_MAX },
+ { "offer_issuer_id", TLV_INVOICE_OFFER_ISSUER_ID },
+ { "invreq_chain", TLV_INVOICE_INVREQ_CHAIN },
+ { "invreq_amount", TLV_INVOICE_INVREQ_AMOUNT },
+ { "invreq_features", TLV_INVOICE_INVREQ_FEATURES },
+ { "invreq_quantity", TLV_INVOICE_INVREQ_QUANTITY },
+ { "invreq_payer_id", TLV_INVOICE_INVREQ_PAYER_ID },
+ { "invreq_payer_note", TLV_INVOICE_INVREQ_PAYER_NOTE },
+ { "invreq_paths", TLV_INVOICE_INVREQ_PATHS },
+ { "invreq_bip_353_name", TLV_INVOICE_INVREQ_BIP_353_NAME },
+ { "invoice_paths", TLV_INVOICE_INVOICE_PATHS },
+ { "invoice_blindedpay", TLV_INVOICE_INVOICE_BLINDEDPAY },
+ { "invoice_created_at", TLV_INVOICE_INVOICE_CREATED_AT },
+ { "invoice_relative_expiry", TLV_INVOICE_INVOICE_RELATIVE_EXPIRY },
+ { "invoice_payment_hash", TLV_INVOICE_INVOICE_PAYMENT_HASH },
+ { "invoice_amount", TLV_INVOICE_INVOICE_AMOUNT },
+ { "invoice_fallbacks", TLV_INVOICE_INVOICE_FALLBACKS },
+ { "invoice_features", TLV_INVOICE_INVOICE_FEATURES },
+ { "invoice_node_id", TLV_INVOICE_INVOICE_NODE_ID },
+ { "signature", TLV_INVOICE_SIGNATURE },
+};
+
+/* Returns name for a TLV type, or NULL if unknown. */
+static const char *tlv_type_name(bigsize_t num)
+{
+ for (size_t i = 0; i < ARRAY_SIZE(field_names); i++) {
+ if (field_names[i].num == num)
+ return field_names[i].name;
+ }
+ return NULL;
+}
+
+/* Returns TLV type number for a field name, or UINT64_MAX if unknown. */
+static bigsize_t tlv_name_to_num(const char *name)
+{
+ for (size_t i = 0; i < ARRAY_SIZE(field_names); i++) {
+ if (streq(field_names[i].name, name))
+ return field_names[i].num;
+ }
+ return UINT64_MAX;
+}
+
+/* Which "layer" does this TLV type belong to? */
+enum field_layer { LAYER_OFFER, LAYER_INVREQ, LAYER_INVOICE, LAYER_UNKNOWN };
+
+static enum field_layer tlv_layer(bigsize_t num)
+{
+ /* BOLT #12:
+ * A writer of an offer:
+ * - MUST NOT set any TLV fields outside the inclusive ranges:
+ 1 to 79 and 1000000000 to 1999999999. */
+ if ((num >= 1 && num <= 79)
+ || (num >= 1000000000 && num <= 1999999999))
+ return LAYER_OFFER;
+ /* BOLT #12:
+ * ## Requirements for Invoice Requests
+ * The writer:
+ *...
+ * MUST NOT set any non-signature TLV fields outside the inclusive
+ * ranges: 0 to 159 and 1000000000 to 2999999999
+ */
+ if ((num >= 80 && num <= 159)
+ || (num >= 2000000000 && num <= 2999999999))
+ return LAYER_INVREQ;
+ /* BOLT #12:
+ * *signature TLV elements*: TLV types 240 through 1000 (inclusive)
+ */
+ /* So, by implication, <= 239 is the invoice field, BUT we also
+ * copy the signature field */
+ if ((num >= 160 && num <= 239)
+ || (num >= 3000000000 && num <= 3999999999)
+ || num == TLV_INVOICE_SIGNATURE)
+ return LAYER_INVOICE;
+
+ return LAYER_UNKNOWN;
+}
+
+static bool u64arr_contains(const bigsize_t *types, bigsize_t type)
+{
+ for (size_t i = 0; i < tal_count(types); i++) {
+ if (types[i] == type)
+ return true;
+ }
+ return false;
+}
+
+/* Callback for make_unsigned_proof: include field if in include_types array. */
+static bool include_field_fn(const struct tlv_field *f, bigsize_t *types)
+{
+ return u64arr_contains(types, f->numtype);
+}
+
+struct one_proof {
+ struct createproof_state *state;
+
+ const struct tlv_invoice *inv;
+ struct preimage preimage;
+ struct tlv_payer_proof *pptlv;
+ /* Filled in as sigs arrive */
+ const char *encoded;
+};
+
+/* Describes the decoded form of the `invstring` parameter. */
+enum bolt12_invtype { INVTYPE_INVOICE, INVTYPE_OFFER, INVTYPE_BIP353 };
+
+struct bolt12_invinfo {
+ const char *invstring; /* canonicalized */
+ enum bolt12_invtype type;
+ /* INVTYPE_OFFER: */
+ struct sha256 offer_id;
+ /* INVTYPE_BIP353: */
+ struct bip_353_name bip353;
+};
+
+struct createproof_state {
+ const char *note;
+ bigsize_t *include_types; /* tal_arr */
+
+ struct bolt12_invinfo *invinfo;
+
+ /* One for each proof we're getting sigs for */
+ struct one_proof **proofs;
+ /* Count down to how many sigs remaining */
+ size_t n_outstanding;
+};
+
+/* Parse a 64-byte bip340 signature from a JSON hex token. */
+static bool json_to_bip340sig(const char *buf, const jsmntok_t *tok,
+ struct bip340sig *sig)
+{
+ return hex_decode(buf + tok->start, tok->end - tok->start,
+ sig->u8, sizeof(sig->u8));
+}
+
+static void json_add_tlv_arr_level(struct json_stream *js,
+ const char *fieldname,
+ enum field_layer layer,
+ const struct tlv_field *fields)
+{
+ json_array_start(js, fieldname);
+ for (size_t i = 0; i < tal_count(fields); i++) {
+ const char *name;
+
+ if (tlv_layer(fields[i].numtype) != layer)
+ continue;
+
+ name = tlv_type_name(fields[i].numtype);
+ if (name)
+ json_add_string(js, NULL, name);
+ else
+ json_add_u64(js, NULL, fields[i].numtype);
+ }
+ json_array_end(js);
+}
+
+/* Output the fields_included arrays for a payer_proof TLV. */
+static void json_add_fields_included(struct json_stream *js,
+ const struct tlv_payer_proof *pptlv)
+{
+ json_add_tlv_arr_level(js, "offer_fields_included",
+ LAYER_OFFER, pptlv->fields);
+ json_add_tlv_arr_level(js, "invreq_fields_included",
+ LAYER_INVREQ, pptlv->fields);
+ json_add_tlv_arr_level(js, "invoice_fields_included",
+ LAYER_INVOICE, pptlv->fields);
+}
+
+static struct command_result *payersign_done(struct command *cmd,
+ const char *method UNNEEDED,
+ const char *buf,
+ const jsmntok_t *result,
+ struct one_proof *proof)
+{
+ struct createproof_state *state = proof->state;
+ const jsmntok_t *sigtok;
+
+ proof->pptlv->proof_signature = tal(proof->pptlv, struct bip340sig);
+ sigtok = json_get_member(buf, result, "signature");
+ if (!sigtok)
+ plugin_err(cmd->plugin, "payersign: no signature in result");
+ if (!json_to_bip340sig(buf, sigtok, proof->pptlv->proof_signature))
+ plugin_err(cmd->plugin, "payersign: bad signature hex");
+
+ /* Encode as lnp1... string */
+ proof->encoded = payer_proof_encode(proof, proof->pptlv);
+
+ /* When all outstanding are done, output all proofs and finish */
+ if (--state->n_outstanding != 0)
+ return command_still_pending(cmd);
+
+ struct json_stream *response = jsonrpc_stream_success(cmd);
+ json_array_start(response, "proofs");
+ for (size_t i = 0; i < tal_count(state->proofs); i++) {
+ json_object_start(response, NULL);
+ json_add_string(response, "bolt12", state->proofs[i]->encoded);
+ json_add_fields_included(response, state->proofs[i]->pptlv);
+ json_object_end(response);
+ }
+ json_array_end(response);
+ return command_finished(cmd, response);
+}
+
+static struct command_result *listsendpays_done(struct command *cmd,
+ const char *method UNNEEDED,
+ const char *buf,
+ const jsmntok_t *result,
+ struct createproof_state *state)
+{
+ const jsmntok_t *arr, *t;
+ size_t i;
+
+ arr = json_get_member(buf, result, "payments");
+ if (!arr || arr->type != JSMN_ARRAY)
+ return command_fail(cmd, LIGHTNINGD,
+ "Unexpected listsendpays result");
+
+ state->proofs = tal_arr(state, struct one_proof *, 0);
+
+ json_for_each_arr(i, t, arr) {
+ const jsmntok_t *statustok, *b12tok, *preimagetok;
+ const char *b12str;
+ struct tlv_invoice *inv;
+ struct preimage preimage;
+ const char *fail;
+ struct one_proof *proof;
+ struct sha256 mroot;
+ struct out_req *req;
+
+ statustok = json_get_member(buf, t, "status");
+ if (!statustok || !json_tok_streq(buf, statustok, "complete"))
+ continue;
+
+ preimagetok = json_get_member(buf, t, "payment_preimage");
+ if (!preimagetok)
+ continue;
+ if (!json_to_preimage(buf, preimagetok, &preimage))
+ continue;
+
+ b12tok = json_get_member(buf, t, "bolt12");
+ if (!b12tok)
+ continue;
+
+ b12str = json_strdup(tmpctx, buf, b12tok);
+ inv = invoice_decode(tmpctx, b12str, strlen(b12str),
+ NULL, chainparams, &fail);
+ if (!inv)
+ continue;
+
+ switch (state->invinfo->type) {
+ case INVTYPE_OFFER: {
+ /* offer_id matching means invoice is for this offer */
+ struct sha256 inv_offer_id;
+ invoice_offer_id(inv, &inv_offer_id);
+ if (!sha256_eq(&state->invinfo->offer_id, &inv_offer_id))
+ continue;
+ break;
+ }
+ case INVTYPE_BIP353:
+ if (!inv->invreq_bip_353_name)
+ continue;
+ if (!memeq(state->invinfo->bip353.name,
+ tal_bytelen(state->invinfo->bip353.name),
+ inv->invreq_bip_353_name->name,
+ tal_bytelen(inv->invreq_bip_353_name->name)))
+ continue;
+ if (!memeq(state->invinfo->bip353.domain,
+ tal_bytelen(state->invinfo->bip353.domain),
+ inv->invreq_bip_353_name->domain,
+ tal_bytelen(inv->invreq_bip_353_name->domain)))
+ continue;
+ break;
+ case INVTYPE_INVOICE:
+ /* We already told listsendpays to only give us this invoice */
+ break;
+ }
+
+ proof = tal(state->proofs, struct one_proof);
+ proof->state = state;
+ proof->inv = tal_steal(proof, inv);
+ proof->preimage = preimage;
+ proof->pptlv = make_unsigned_proof(proof,
+ proof->inv,
+ &proof->preimage,
+ state->note,
+ include_field_fn,
+ state->include_types);
+ bolt12_payer_proof_merkle(proof->pptlv, &mroot);
+
+ req = jsonrpc_request_start(cmd, "payersign",
+ &payersign_done,
+ &plugin_broken_cb,
+ proof);
+ json_add_string(req->js, "messagename", "payer_proof");
+ json_add_string(req->js, "fieldname", "proof_signature");
+ json_add_sha256(req->js, "merkle", &mroot);
+ json_add_hex(req->js, "tweak",
+ proof->inv->invreq_metadata,
+ tal_bytelen(proof->inv->invreq_metadata));
+ send_outreq(req);
+
+ tal_arr_expand(&state->proofs, proof);
+ }
+
+ state->n_outstanding = tal_count(state->proofs);
+ if (state->n_outstanding == 0)
+ return command_fail(cmd, CREATEPROOF_NO_PAYMENT,
+ "No successful payment found for that invstring");
+
+ return command_still_pending(cmd);
+}
+
+/* param_ callback for the `include` array parameter.
+ * Accepts an array of field name strings or u64 numbers.
+ * Always prepends the mandatory_types. */
+static struct command_result *param_include_types(struct command *cmd,
+ const char *name,
+ const char *buf,
+ const jsmntok_t *tok,
+ bigsize_t **types)
+{
+ const jsmntok_t *t;
+ size_t i;
+
+ if (tok->type != JSMN_ARRAY)
+ return command_fail_badparam(cmd, name, buf, tok,
+ "Expected array");
+
+ *types = tal_arr(cmd, bigsize_t, 0);
+
+ /* Always start with mandatory types */
+ for (size_t j = 0; j < ARRAY_SIZE(mandatory_types); j++)
+ tal_arr_expand(types, mandatory_types[j]);
+
+ /* Add user-specified fields, skipping duplicates */
+ json_for_each_arr(i, t, tok) {
+ bigsize_t num;
+
+ if (t->type == JSMN_STRING) {
+ char *fname = json_strdup(tmpctx, buf, t);
+ num = tlv_name_to_num(fname);
+ if (num == UINT64_MAX)
+ return command_fail_badparam(cmd, name, buf, t,
+ "Unknown field name");
+ } else if (t->type == JSMN_PRIMITIVE) {
+ u64 v;
+ if (!json_to_u64(buf, t, &v))
+ return command_fail_badparam(cmd, name, buf, t,
+ "Expected name or number");
+ num = v;
+ } else {
+ return command_fail_badparam(cmd, name, buf, t,
+ "Expected name or number");
+ }
+
+ if (!u64arr_contains(*types, num))
+ tal_arr_expand(types, num);
+ }
+
+ return NULL;
+}
+
+/* param_ callback for `invstring`: canonicalizes and decodes offer/BIP353. */
+static struct command_result *param_bolt12_invstring(struct command *cmd,
+ const char *name,
+ const char *buf,
+ const jsmntok_t *tok,
+ struct bolt12_invinfo **invinfo)
+{
+ const char *invstring = to_canonical_invstr(cmd, json_strdup(tmpctx, buf, tok));
+ const char *fail;
+
+ *invinfo = tal(cmd, struct bolt12_invinfo);
+ (*invinfo)->invstring = invstring;
+
+ if (strstarts(invstring, "lni1")) {
+ (*invinfo)->type = INVTYPE_INVOICE;
+ } else if (strstarts(invstring, "lno1")) {
+ struct tlv_offer *offer;
+ offer = offer_decode(tmpctx, invstring, strlen(invstring),
+ NULL, chainparams, &fail);
+ if (!offer)
+ return command_fail_badparam(cmd, name, buf, tok,
+ tal_fmt(tmpctx, "Invalid offer: %s", fail));
+ offer_offer_id(offer, &(*invinfo)->offer_id);
+ (*invinfo)->type = INVTYPE_OFFER;
+ } else if (strchr(invstring, '@')) {
+ /* BOLT #12:
+ * - if it received the offer from which it constructed this
+ * `invoice_request` using BIP 353 resolution:
+ * - MUST include `invreq_bip_353_name` with,
+ * - `name` set to the post-₿, pre-@ part of the BIP 353 HRN,
+ * - `domain` set to the post-@ part of the BIP 353 HRN.
+ */
+ char *str = json_strdup(tmpctx, buf, tok);
+ char *at;
+
+ if (!utf8_check(str, strlen(str)))
+ return command_fail_badparam(cmd, name, buf, tok,
+ "Invalid UTF-8");
+ /* Strip ₿ if present (0xE2 0x82 0xBF) */
+ if (strstarts(str, "₿"))
+ str += strlen("₿");
+ at = strchr(str, '@');
+ if (!at)
+ return command_fail_badparam(cmd, name, buf, tok,
+ "Missing @ in BIP353 address");
+ (*invinfo)->bip353.name
+ = tal_dup_arr(*invinfo, u8, (const u8 *)str,
+ at - str, 0);
+ (*invinfo)->bip353.domain
+ = tal_dup_arr(*invinfo, u8, (const u8 *)(at + 1),
+ strlen(at + 1), 0);
+ (*invinfo)->type = INVTYPE_BIP353;
+ } else {
+ return command_fail_badparam(cmd, name, buf, tok,
+ "Expected bolt12 invoice (lni1...), "
+ "offer (lno1...), or user@domain");
+ }
+ return NULL;
+}
+
+struct command_result *json_createproof(struct command *cmd,
+ const char *buffer,
+ const jsmntok_t *params)
+{
+ struct createproof_state *state;
+ struct out_req *req;
+
+ state = tal(cmd, struct createproof_state);
+ if (!param(cmd, buffer, params,
+ p_req("invstring", param_bolt12_invstring, &state->invinfo),
+ p_opt("note", param_string, &state->note),
+ p_opt("include", param_include_types, &state->include_types),
+ NULL))
+ return command_param_failed();
+
+ if (!state->include_types) {
+ /* Default: mandatory + extras */
+ state->include_types = tal_arr(state, bigsize_t, 0);
+ for (size_t i = 0; i < ARRAY_SIZE(mandatory_types); i++)
+ tal_arr_expand(&state->include_types, mandatory_types[i]);
+ for (size_t i = 0; i < ARRAY_SIZE(default_extras); i++)
+ tal_arr_expand(&state->include_types, default_extras[i]);
+ }
+
+ /* Look up completed payments.
+ * For bolt12 invoices, listsendpays filters by payment_hash.
+ * For offers and BIP353, we get all and filter in the callback. */
+ req = jsonrpc_request_start(cmd, "listsendpays",
+ &listsendpays_done,
+ &forward_error,
+ state);
+ if (state->invinfo->type == INVTYPE_INVOICE)
+ json_add_string(req->js, "bolt11", state->invinfo->invstring);
+ json_add_string(req->js, "status", "complete");
+ return send_outreq(req);
+}
diff --git a/plugins/offers_proof.h b/plugins/offers_proof.h
new file mode 100644
index 00000000..605aebfd
--- /dev/null
+++ b/plugins/offers_proof.h
@@ -0,0 +1,12 @@
+#ifndef LIGHTNING_PLUGINS_OFFERS_PROOF_H
+#define LIGHTNING_PLUGINS_OFFERS_PROOF_H
+#include "config.h"
+
+struct command_result;
+struct command;
+
+struct command_result *json_createproof(struct command *cmd,
+ const char *buffer,
+ const jsmntok_t *params);
+
+#endif /* LIGHTNING_PLUGINS_OFFERS_PROOF_H */
diff --git a/plugins/test/run-decode_guess_type.c b/plugins/test/run-decode_guess_type.c
index f4fc98e4..038f3124 100644
--- a/plugins/test/run-decode_guess_type.c
+++ b/plugins/test/run-decode_guess_type.c
@@ -112,6 +112,11 @@ struct command_result *json_cancelrecurringinvoice(struct command *cmd UNNEEDED,
const char *buffer UNNEEDED,
const jsmntok_t *params UNNEEDED)
{ fprintf(stderr, "json_cancelrecurringinvoice called!\n"); abort(); }
+/* Generated stub for json_createproof */
+struct command_result *json_createproof(struct command *cmd UNNEEDED,
+ const char *buffer UNNEEDED,
+ const jsmntok_t *params UNNEEDED)
+{ fprintf(stderr, "json_createproof called!\n"); abort(); }
/* Generated stub for json_dev_rawrequest */
struct command_result *json_dev_rawrequest(struct command *cmd UNNEEDED,
const char *buffer UNNEEDED,
diff --git a/tests/test_pay.py b/tests/test_pay.py
index 79ec022e..5294e175 100644
--- a/tests/test_pay.py
+++ b/tests/test_pay.py
@@ -7171,3 +7171,116 @@ def test_blinded_path_max(node_factory):
offer = l2.rpc.offer('any')['bolt12']
inv = l1.rpc.fetchinvoice(offer, '10000msat')['invoice']
assert only_one(l1.rpc.decode(inv)['invoice_paths'])['payinfo']['htlc_maximum_msat'] > 0, f"bad paths for offer = {offer}, invoice = {inv}"
+
+
+def test_createproof(node_factory, bitcoind):
+ """Basic createproof tests: invoice string, offer string, no payment, note, include."""
+ l1, l2, l3 = node_factory.line_graph(3, wait_for_announce=True,
+ opts=[{},
+ {'dev-allow-localhost': None},
+ {'dev-allow-localhost': None}])
+
+ offer = l3.rpc.offer('10000msat', 'test offer')
+ inv = l1.rpc.fetchinvoice(offer['bolt12'])['invoice']
+
+ # No payment yet: should fail with error 1700.
+ with pytest.raises(RpcError, match=r'1700.*No successful payment'):
+ l1.rpc.call('createproof', {'invstring': inv})
+
+ l1.rpc.xpay(inv)
+
+ # Proof by invoice string.
+ proof = only_one(l1.rpc.call('createproof', {'invstring': inv})['proofs'])
+
+ # Check returned field lists.
+ assert 'offer_description' in proof['offer_fields_included']
+ assert 'offer_issuer_id' in proof['offer_fields_included']
+ assert 'invreq_payer_id' in proof['invreq_fields_included']
+ assert 'invoice_payment_hash' in proof['invoice_fields_included']
+ assert 'invoice_amount' in proof['invoice_fields_included']
+ assert 'invoice_features' in proof['invoice_fields_included']
+ assert 'invoice_node_id' in proof['invoice_fields_included']
+ assert 'signature' in proof['invoice_fields_included']
+
+ # Decode the proof: must be valid.
+ decoded = l1.rpc.decode(proof['bolt12'])
+ assert decoded['type'] == 'bolt12 payer_proof'
+ assert decoded['valid'] is True
+
+ # Proof by offer string gives the same proof.
+ assert only_one(l1.rpc.call('createproof', {'invstring': offer['bolt12']})['proofs'])['bolt12'] == proof['bolt12']
+
+ # Second payment to same offer gives two proofs.
+ inv2 = l1.rpc.fetchinvoice(offer['bolt12'])['invoice']
+ l1.rpc.xpay(inv2)
+ ret3 = l1.rpc.call('createproof', {'invstring': offer['bolt12']})
+ assert len(ret3['proofs']) == 2
+
+ # Both decode as valid.
+ for p in ret3['proofs']:
+ d = l1.rpc.decode(p['bolt12'])
+ assert d['valid'] is True
+
+
+def test_createproof_note(node_factory, bitcoind):
+ """Proof with a note: decoded proof should include it."""
+ l1, l2, l3 = node_factory.line_graph(3, wait_for_announce=True,
+ opts=[{},
+ {'dev-allow-localhost': None},
+ {'dev-allow-localhost': None}])
+
+ offer = l3.rpc.offer('5000msat', 'note test')
+ inv = l1.rpc.fetchinvoice(offer['bolt12'])['invoice']
+ l1.rpc.xpay(inv)
+
+ ret = l1.rpc.call('createproof', {'invstring': inv, 'note': 'I paid for this!'})
+ proof = only_one(ret['proofs'])
+
+ decoded = l1.rpc.decode(proof['bolt12'])
+ assert decoded['valid'] is True
+ assert decoded['proof_note'] == 'I paid for this!'
+
+ # Proof without note decodes fine with absent payer_note.
+ ret2 = l1.rpc.call('createproof', {'invstring': inv})
+ decoded2 = l1.rpc.decode(only_one(ret2['proofs'])['bolt12'])
+ assert decoded2['valid'] is True
+ assert 'proof_note' not in decoded2
+
+
+def test_createproof_include(node_factory, bitcoind):
+ """Proof with custom include list: only requested fields appear."""
+ l1, l2, l3 = node_factory.line_graph(3, wait_for_announce=True,
+ opts=[{},
+ {'dev-allow-localhost': None},
+ {'dev-allow-localhost': None}])
+
+ offer = l3.rpc.offer('10000msat', 'include test')
+ inv = l1.rpc.fetchinvoice(offer['bolt12'])['invoice']
+ l1.rpc.xpay(inv)
+
+ # Include only invoice_amount by name, plus the mandatory fields.
+ ret = l1.rpc.call('createproof', {'invstring': inv,
+ 'include': ['invoice_amount']})
+ proof = only_one(ret['proofs'])
+
+ # Mandatory fields always present.
+ assert 'invreq_payer_id' in proof['invreq_fields_included']
+ assert 'invoice_payment_hash' in proof['invoice_fields_included']
+ assert 'invoice_features' in proof['invoice_fields_included']
+ assert 'invoice_node_id' in proof['invoice_fields_included']
+ assert 'signature' in proof['invoice_fields_included']
+
+ # invoice_amount is there; but offer_description is not (not in our include list).
+ assert 'invoice_amount' in proof['invoice_fields_included']
+ assert 'offer_description' not in proof['offer_fields_included']
+
+ decoded = l1.rpc.decode(proof['bolt12'])
+ assert decoded['valid'] is True
+
+ # Include by number works too (170 = invoice_amount).
+ ret2 = l1.rpc.call('createproof', {'invstring': inv, 'include': [170]})
+ assert only_one(ret2['proofs'])['bolt12'] == proof['bolt12']
+
+ # Unknown name is an error.
+ with pytest.raises(RpcError, match=r'Unknown field name'):
+ l1.rpc.call('createproof', {'invstring': inv, 'include': ['no_such_field']})
Why this scored 22/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.