Merge PR 'Add a BOLT 12 payer proof verification crate' (#4839)
What changed, and why it matters
This commit adds a brand-new, optional crate called lightning-payer-proof to the rust-lightning workspace. It provides a public library (and separate UniFFI bindings) for verifying BOLT 12 'payer proofs' — cryptographic receipts that a Lightning payment was made. The change is purely additive: it introduces new code, test vectors, CI build entries, and workspace membership. There is no patch to existing logic and nothing in the commit message or diff indicates a security bug was fixed.
No security action required. Treat as a normal feature addition. Reviewers may optionally audit the underlying lightning::offers::payer_proof validation logic and the UniFFI FFI surface for correctness, but those are not changed by this commit.
Security signals we found
New cryptographic verification crate added to workspace
Uses existing LDK payer_proof validation; no new crypto implementation visible in diff
Documentation explicitly warns that verification alone does not prove payment to a specific invoice
No unsafe code in core crate (forbid(unsafe_code))
No changes to existing crates' logic or security-critical paths
No vendor disclosure of vulnerability or security fix
Evidence from the diff
The merge commit imports the lightning-payer-proof crate and its UniFFI wrapper into main. The core crate exposes verify() and verify_bytes() which parse and cryptographically validate BOLT 12 payer proofs (lnp1… strings or raw TLV bytes), returning a VerifiedPayerProof with accessors for payment hash/preimage, signing pubkeys, invoice metadata, merkle root, and signatures. It also exposes pays_offers_recipient() to check whether a proof’s issuer matches a given BOLT 12 offer. The implementation delegates parsing and validation to the existing lightning::offers::payer_proof module. The UniFFI crate flattens the verified proof into a dictionary for foreign-language bindings. CI scripts are updated to include the new crate in c_bindings and no_std test loops, and it is added to msrv-no-dev-deps-check and no-std-check manifests. No existing files are modified beyond workspace/CI integration.
Changed components
lightning-payer-proof (new crate)lightning-payer-proof/uniffi (new UniFFI bindings crate)workspace Cargo.tomlCI scripts ci-tests-bindings.sh and ci-tests-nostd.shmsrv-no-dev-deps-check/Cargo.tomlno-std-check/Cargo.tomlInspect captured patch +892 / −2
### .gitignore
@@ -13,6 +13,8 @@ lightning-rapid-gossip-sync/res/full_graph.lngossip
lightning-custom-message/target
lightning-transaction-sync/target
lightning-dns-resolver/target
+lightning-payer-proof/target
+lightning-payer-proof/uniffi/target
ext-functional-test-demo/target
no-std-check/target
msrv-no-dev-deps-check/target
### Cargo.toml
@@ -8,6 +8,7 @@ members = [
"lightning-types",
"lightning-block-sync",
"lightning-invoice",
+ "lightning-payer-proof",
"lightning-net-tokio",
"lightning-persister",
"lightning-background-processor",
### ci/ci-tests-bindings.sh
@@ -9,7 +9,7 @@ echo -e "\n\nTesting c_bindings builds"
# disable doctests in `c_bindings` so we skip doctests entirely here.
RUSTFLAGS="$RUSTFLAGS --cfg=c_bindings" cargo test --quiet --color always --lib --bins --tests
-for DIR in lightning-invoice lightning-rapid-gossip-sync; do
+for DIR in lightning-invoice lightning-payer-proof lightning-rapid-gossip-sync; do
# check if there is a conflict between no_std and the c_bindings cfg
RUSTFLAGS="$RUSTFLAGS --cfg=c_bindings" cargo test -p $DIR --quiet --color always --no-default-features
done
### ci/ci-tests-nostd.sh
@@ -5,7 +5,7 @@ set -eox pipefail
source "$(dirname "$0")/ci-tests-common.sh"
echo -e "\n\nTesting no_std builds"
-for DIR in lightning-invoice lightning-rapid-gossip-sync lightning-liquidity; do
+for DIR in lightning-invoice lightning-payer-proof lightning-rapid-gossip-sync lightning-liquidity; do
cargo test -p $DIR --quiet --color always --no-default-features
done
### lightning-payer-proof/Cargo.toml
@@ -0,0 +1,28 @@
+[package]
+name = "lightning-payer-proof"
+version = "0.1.0+git"
+authors = ["Vincenzo Palazzo <vincenzopalazzodev@gmail.com>", "Matt Corallo"]
+license = "MIT OR Apache-2.0"
+documentation = "https://docs.rs/lightning-payer-proof/"
+repository = "https://git.rust-bitcoin.org/lightningdevkit/rust-lightning"
+readme = "../README.md"
+keywords = [ "lightning", "bitcoin", "bolt12", "payment" ]
+description = """
+Verification of BOLT 12 payer proofs.
+"""
+edition = "2021"
+rust-version = "1.75"
+
+[package.metadata.docs.rs]
+all-features = true
+rustdoc-args = ["--cfg", "docsrs"]
+
+[features]
+default = ["std"]
+std = ["lightning/std"]
+
+[dependencies]
+lightning = { version = "0.3.0", path = "../lightning", default-features = false }
+
+[lints]
+workspace = true
### lightning-payer-proof/src/lib.rs
@@ -0,0 +1,518 @@
+// This file is Copyright its original authors, visible in version control
+// history.
+//
+// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE
+// or http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
+// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your option.
+// You may not use this file except in accordance with one or both of these
+// licenses.
+
+//! Verification of BOLT 12 payer proofs.
+//!
+//! * Verify a bech32 proof with [`verify`] (`lnp1...`) or [`verify_bytes`]
+//! * Inspect the result with [`VerifiedPayerProof`]
+//! * Check an offer's recipient with [`VerifiedPayerProof::pays_offers_recipient`]
+//!
+//! A successful verify does not mean they paid the invoice you have in mind. Anyone can issue an
+//! invoice to themselves, pay it, and hand out a proof that verifies.
+//!
+//! ```ignore
+//! use lightning_payer_proof::{verify, Offer};
+//!
+//! let encoded = "lnp1...";
+//! let offer = "lno1...";
+//!
+//! let proof = verify(encoded).unwrap();
+//! let offer: Offer = offer.parse().unwrap();
+//! assert!(proof.pays_offers_recipient(&offer));
+//! ```
+
+#![cfg_attr(all(not(feature = "std"), not(test)), no_std)]
+#![deny(missing_docs)]
+#![deny(rustdoc::broken_intra_doc_links)]
+#![deny(rustdoc::private_intra_doc_links)]
+#![deny(non_upper_case_globals)]
+#![deny(non_camel_case_types)]
+#![deny(non_snake_case)]
+#![deny(unused_mut)]
+#![forbid(unsafe_code)]
+#![cfg_attr(docsrs, feature(doc_cfg))]
+
+extern crate alloc;
+
+use alloc::string::{String, ToString};
+use alloc::vec::Vec;
+
+use lightning::bitcoin::hashes::Hash;
+use lightning::offers::parse::Bolt12ParseError;
+use lightning::offers::payer_proof::PayerProof;
+
+pub use lightning::bitcoin::secp256k1::PublicKey;
+pub use lightning::offers::offer::Offer;
+pub use lightning::offers::payer_proof::PayerProof as UnderlyingPayerProof;
+pub use lightning::types::payment::{PaymentHash, PaymentPreimage};
+pub use lightning::types::string::UntrustedString;
+
+/// Why a payer proof was rejected.
+///
+/// Every variant means the proof is unusable. They are distinguished so a verifier can tell a
+/// mistyped string apart from a proof that decoded cleanly but failed a cryptographic check, which
+/// are very different things to show a user.
+///
+/// Failed cryptographic checks currently all arrive as [`MalformedProof`].
+///
+/// [`MalformedProof`]: Self::MalformedProof
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum VerifyError {
+ /// The string is not a bech32-encoded payer proof, or carries a prefix other than `lnp`.
+ InvalidBech32,
+ /// The bytes decoded as bech32 but are not a well-formed payer proof TLV stream.
+ ///
+ /// Failed cryptographic checks currently arrive here too.
+ MalformedProof,
+ /// The TLV stream is well-formed but omits a field required to verify the proof at all, such
+ /// as the payment hash, the preimage, or one of the signatures.
+ IncompleteProof,
+}
+
+impl core::fmt::Display for VerifyError {
+ fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
+ let message = match self {
+ VerifyError::InvalidBech32 => "not a bech32-encoded payer proof",
+ VerifyError::MalformedProof => "malformed payer proof",
+ VerifyError::IncompleteProof => "payer proof is missing a required field",
+ };
+ f.write_str(message)
+ }
+}
+
+#[cfg(feature = "std")]
+impl std::error::Error for VerifyError {}
+
+impl From<Bolt12ParseError> for VerifyError {
+ fn from(error: Bolt12ParseError) -> Self {
+ match error {
+ Bolt12ParseError::InvalidContinuation
+ | Bolt12ParseError::InvalidLeadingWhitespace
+ | Bolt12ParseError::InvalidBech32Hrp
+ | Bolt12ParseError::Bech32(_)
+ | Bolt12ParseError::InvalidPadding(_) => VerifyError::InvalidBech32,
+ // Every failed cryptographic check arrives here too, indistinguishable from a stream
+ // that was never well-formed.
+ Bolt12ParseError::Decode(_) => VerifyError::MalformedProof,
+ Bolt12ParseError::InvalidSemantics(_) => VerifyError::IncompleteProof,
+ // Raised when a key or signature fails to deserialize, which leaves us unable to say
+ // anything about whether the signature would have verified.
+ Bolt12ParseError::InvalidSignature(_) => VerifyError::MalformedProof,
+ }
+ }
+}
+
+/// Verifies a bech32-encoded payer proof, the `lnp1...` string a payer hands over.
+///
+/// Uppercase input, and the `+` continuation from BOLT 12 allowing long strings to be split across
+/// lines, are both accepted.
+pub fn verify(proof: &str) -> Result<VerifiedPayerProof, VerifyError> {
+ proof.parse::<PayerProof>().map(VerifiedPayerProof).map_err(VerifyError::from)
+}
+
+/// Verifies a payer proof in its raw TLV stream form, skipping the bech32 layer.
+pub fn verify_bytes(proof: &[u8]) -> Result<VerifiedPayerProof, VerifyError> {
+ PayerProof::try_from(proof.to_vec()).map(VerifiedPayerProof).map_err(VerifyError::from)
+}
+
+/// A payer proof that passed every check in [`verify`].
+///
+/// Fields the payer withheld return `None`.
+#[derive(Clone, Debug)]
+pub struct VerifiedPayerProof(PayerProof);
+
+impl VerifiedPayerProof {
+ /// The payment hash this proof settles.
+ ///
+ /// Compare this against your own records to learn whether the proof concerns you at all.
+ pub fn payment_hash(&self) -> PaymentHash {
+ self.0.payment_hash()
+ }
+
+ /// The preimage that unlocked the payment, proven here to hash to [`Self::payment_hash`].
+ pub fn payment_preimage(&self) -> PaymentPreimage {
+ self.0.payment_preimage()
+ }
+
+ /// The key the payer signed this proof with.
+ pub fn payer_signing_pubkey(&self) -> PublicKey {
+ self.0.payer_signing_pubkey()
+ }
+
+ /// The key the invoice was signed with, identifying who issued it.
+ pub fn issuer_signing_pubkey(&self) -> PublicKey {
+ self.0.issuer_signing_pubkey()
+ }
+
+ /// The invoiced amount in millisatoshis, if disclosed.
+ pub fn invoice_amount_msats(&self) -> Option<u64> {
+ self.0.invoice_amount_msats()
+ }
+
+ /// When the invoice was created, in seconds since the Unix epoch, if disclosed.
+ pub fn invoice_created_at_secs(&self) -> Option<u64> {
+ self.0.invoice_created_at().map(|created_at| created_at.as_secs())
+ }
+
+ /// The offer description the invoice was built from, if disclosed.
+ ///
+ /// Untrusted text; sanitize control characters before displaying.
+ pub fn offer_description(&self) -> Option<UntrustedString> {
+ self.0.offer_description().map(|text| UntrustedString(text.0.to_string()))
+ }
+
+ /// The offer issuer, if disclosed.
+ ///
+ /// This is a human-readable label chosen by whoever built the offer, not an identity. It is
+ /// [`Self::issuer_signing_pubkey`] that says who signed.
+ pub fn offer_issuer(&self) -> Option<UntrustedString> {
+ self.0.offer_issuer().map(|text| UntrustedString(text.0.to_string()))
+ }
+
+ /// A note the payer attached when building the proof, if any.
+ pub fn proof_note(&self) -> Option<UntrustedString> {
+ self.0.proof_note().map(|text| UntrustedString(text.0.to_string()))
+ }
+
+ /// The merkle root of the invoice the issuer signed.
+ pub fn merkle_root(&self) -> [u8; 32] {
+ self.0.merkle_root().to_byte_array()
+ }
+
+ /// The issuer's signature over the invoice, in BIP 340 form.
+ pub fn invoice_signature(&self) -> [u8; 64] {
+ *self.0.invoice_signature().as_ref()
+ }
+
+ /// The payer's signature over this proof, in BIP 340 form.
+ pub fn proof_signature(&self) -> [u8; 64] {
+ *self.0.proof_signature().as_ref()
+ }
+
+ /// Re-encodes the proof as the `lnp1...` string it was parsed from.
+ pub fn to_bech32(&self) -> String {
+ self.0.to_string()
+ }
+
+ /// The proof's raw TLV stream, as accepted by [`verify_bytes`].
+ pub fn encode(&self) -> Vec<u8> {
+ self.0.bytes().to_vec()
+ }
+
+ /// Whether the invoice this proof covers was issued by `offer`'s recipient.
+ ///
+ /// Inlined here so this crate can answer the question against LDK 0.3, which has the
+ /// accessors but not `PayerProof::pays_offers_recipient`.
+ ///
+ /// This identifies the recipient, not the offer: two offers published by the same recipient
+ /// are indistinguishable here, so a `true` answers "this was paid to whoever `offer` names"
+ /// rather than "this paid `offer`".
+ pub fn pays_offers_recipient(&self, offer: &Offer) -> bool {
+ let invoice_key = self.0.issuer_signing_pubkey();
+ if let Some(issuer_id) = offer.issuer_signing_pubkey() {
+ return invoice_key == issuer_id;
+ }
+ offer
+ .paths()
+ .iter()
+ .filter_map(|path| path.blinded_hops().last())
+ .any(|last_hop| invoice_key == last_hop.blinded_node_id)
+ }
+
+ /// Whether the invoice this proof covers was issued by the recipient of the offer encoded as
+ /// `offer` (`lno1...`).
+ pub fn pays_offers_recipient_str(&self, offer: &str) -> bool {
+ offer.parse::<Offer>().map(|offer| self.pays_offers_recipient(&offer)).unwrap_or(false)
+ }
+
+ /// The underlying [`PayerProof`], for callers who want to work with LDK's own type.
+ pub fn inner(&self) -> &PayerProof {
+ &self.0
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// A valid payer proof over a 42,000 msat invoice created at Unix time 1,700,000,000,
+ /// disclosing the offer description ("coffee beans"), the issuer ("LDK Roastery"), the amount
+ /// and the creation timestamp, with the payer note "order-1234" attached.
+ const FULL_PROOF_HEX: &str = include_str!("../test_vectors/valid_proof.hex");
+
+ /// A valid payer proof over the same invoice disclosing none of the optional fields and
+ /// carrying no note, so every `Option` accessor returns `None`.
+ const MINIMAL_PROOF_HEX: &str = include_str!("../test_vectors/minimal_proof.hex");
+
+ /// An offer, a proof over an invoice built from it, and a second offer that differs only in
+ /// issuer id, so a recipient check against it must fail.
+ const OFFER_HEX: &str = include_str!("../test_vectors/offer.hex");
+ const OFFER_PROOF_HEX: &str = include_str!("../test_vectors/offer_proof.hex");
+ const OTHER_OFFER_HEX: &str = include_str!("../test_vectors/other_offer.hex");
+
+ /// Both vectors were produced by the `RefundBuilder` -> `prove_payer` -> `sign` path exercised
+ /// by the tests in `lightning::offers::payer_proof`, using that module's fixed test keys. They
+ /// are frozen here rather than regenerated so a change in encoding shows up as a test failure.
+ /// They pin this implementation against itself; BOLT 12 payer proofs have no cross-
+ /// implementation test vectors yet, so nothing here demonstrates interop.
+ fn decode_hex(hex: &str) -> Vec<u8> {
+ let hex = hex.trim();
+ assert!(hex.len() % 2 == 0, "vector must have an even number of hex digits");
+ (0..hex.len())
+ .step_by(2)
+ .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("vector must be valid hex"))
+ .collect()
+ }
+
+ fn full_proof_bytes() -> Vec<u8> {
+ decode_hex(FULL_PROOF_HEX)
+ }
+
+ /// Reads a BigSize-prefixed integer, returning it alongside the offset just past it.
+ fn read_bigsize(bytes: &[u8], offset: usize) -> (u64, usize) {
+ match bytes[offset] {
+ 0xff => {
+ (u64::from_be_bytes(bytes[offset + 1..offset + 9].try_into().unwrap()), offset + 9)
+ },
+ 0xfe => (
+ u32::from_be_bytes(bytes[offset + 1..offset + 5].try_into().unwrap()) as u64,
+ offset + 5,
+ ),
+ 0xfd => (
+ u16::from_be_bytes(bytes[offset + 1..offset + 3].try_into().unwrap()) as u64,
+ offset + 3,
+ ),
+ byte => (byte as u64, offset + 1),
+ }
+ }
+
+ /// Returns the byte range covering the value of the TLV record with the given type.
+ fn tlv_value_range(bytes: &[u8], tlv_type: u64) -> core::ops::Range<usize> {
+ let mut offset = 0;
+ while offset < bytes.len() {
+ let (found_type, after_type) = read_bigsize(bytes, offset);
+ let (length, after_length) = read_bigsize(bytes, after_type);
+ let value = after_length..after_length + length as usize;
+ if found_type == tlv_type {
+ return value;
+ }
+ offset = value.end;
+ }
+ panic!("vector has no TLV of type {}", tlv_type);
+ }
+
+ // TLV types a payer proof is built from, per BOLT 12 and its payer proof extension.
+ const INVOICE_NODE_ID: u64 = 176;
+ const ISSUER_SIGNATURE: u64 = 240;
+ const PROOF_SIGNATURE: u64 = 241;
+ const PROOF_PREIMAGE: u64 = 1001;
+ const PROOF_OMITTED_MARKERS: u64 = 1002;
+ const PROOF_LEAF_HASHES: u64 = 1004;
+ const PROOF_NOTE: u64 = 1005;
+
+ #[test]
+ fn verifies_a_valid_proof() {
+ let proof = verify_bytes(&full_proof_bytes()).unwrap();
+
+ assert_eq!(proof.invoice_amount_msats(), Some(42_000));
+ assert_eq!(proof.invoice_created_at_secs(), Some(1_700_000_000));
+ assert_eq!(proof.offer_description().map(|d| d.0), Some("coffee beans".to_string()));
+ assert_eq!(proof.offer_issuer().map(|i| i.0), Some("LDK Roastery".to_string()));
+ assert_eq!(proof.proof_note().map(|n| n.0), Some("order-1234".to_string()));
+ }
+
+ /// The two keys carry the whole weight of deciding whether a proof concerns the verifier, so
+ /// pin each to its own value rather than only checking that they parse.
+ #[test]
+ fn exposes_the_two_signing_keys_distinctly() {
+ let bytes = full_proof_bytes();
+ let proof = verify_bytes(&bytes).unwrap();
+
+ assert_eq!(
+ proof.issuer_signing_pubkey().serialize().to_vec(),
+ bytes[tlv_value_range(&bytes, INVOICE_NODE_ID)].to_vec()
+ );
+ assert_ne!(proof.payer_signing_pubkey(), proof.issuer_signing_pubkey());
+ }
+
+ #[test]
+ fn exposes_the_signatures_and_merkle_root() {
+ let bytes = full_proof_bytes();
+ let proof = verify_bytes(&bytes).unwrap();
+
+ assert_eq!(
+ proof.invoice_signature().to_vec(),
+ bytes[tlv_value_range(&bytes, ISSUER_SIGNATURE)].to_vec()
+ );
+ assert_eq!(
+ proof.proof_signature().to_vec(),
+ bytes[tlv_value_range(&bytes, PROOF_SIGNATURE)].to_vec()
+ );
+ assert_ne!(proof.merkle_root(), [0; 32], "merkle root must not be left unset");
+ assert_eq!(proof.merkle_root(), proof.inner().merkle_root().to_byte_array());
+ }
+
+ #[test]
+ fn preimage_hashes_to_the_payment_hash() {
+ let proof = verify_bytes(&full_proof_bytes()).unwrap();
+
+ let preimage = proof.payment_preimage();
+ let hash = lightning::bitcoin::hashes::sha256::Hash::hash(&preimage.0);
+ assert_eq!(proof.payment_hash().0, hash.to_byte_array());
+ }
+
+ /// Withholding every optional field is a valid choice by the payer, not an error.
+ #[test]
+ fn verifies_a_proof_disclosing_nothing_optional() {
+ let proof = verify_bytes(&decode_hex(MINIMAL_PROOF_HEX)).unwrap();
+
+ assert_eq!(proof.invoice_amount_msats(), None);
+ assert_eq!(proof.invoice_created_at_secs(), None);
+ assert!(proof.offer_description().is_none());
+ assert!(proof.offer_issuer().is_none());
+ assert!(proof.proof_note().is_none());
+
+ // The fields verification itself needs are still there.
+ assert_ne!(proof.merkle_root(), [0; 32]);
+ assert_ne!(proof.payer_signing_pubkey(), proof.issuer_signing_pubkey());
+ }
+
+ #[test]
+ fn round_trips_through_bech32() {
+ let bytes = full_proof_bytes();
+ let encoded = verify_bytes(&bytes).unwrap().to_bech32();
+
+ assert!(encoded.starts_with("lnp1"), "unexpected prefix: {}", encoded);
+
+ let reparsed = verify(&encoded).unwrap();
+ assert_eq!(reparsed.encode(), bytes);
+ }
+
+ #[test]
+ fn accepts_uppercase_and_continuations() {
+ let encoded = verify_bytes(&full_proof_bytes()).unwrap().to_bech32();
+
+ assert!(verify(&encoded.to_uppercase()).is_ok());
+
+ let (head, tail) = encoded.split_at(encoded.len() / 2);
+ assert!(verify(&alloc::format!("{}+\n {}", head, tail)).is_ok());
+ }
+
+ #[test]
+ fn rejects_non_bech32_input() {
+ assert_eq!(verify("").unwrap_err(), VerifyError::InvalidBech32);
+ assert_eq!(verify("not a proof").unwrap_err(), VerifyError::InvalidBech32);
+ // BOLT 12 strings carry no checksum, so this is rejected on its human-readable part
+ // rather than on its data: `lno` is an offer, not a payer proof.
+ assert_eq!(verify("lno1pqps7sjqpgt").unwrap_err(), VerifyError::InvalidBech32);
+ }
+
+ #[test]
+ fn rejects_empty_and_truncated_bytes() {
+ assert!(verify_bytes(&[]).is_err());
+
+ let bytes = full_proof_bytes();
+ assert!(verify_bytes(&bytes[..bytes.len() - 8]).is_err());
+ }
+
+ /// Appending a TLV the proof never committed to must not be accepted, or a proof could be
+ /// extended after signing.
+ #[test]
+ fn rejects_appended_data() {
+ let mut bytes = full_proof_bytes();
+ bytes.extend_from_slice(&[0xfd, 0x27, 0x11, 0x01, 0x00]);
+
+ assert!(verify_bytes(&bytes).is_err());
+ }
+
+ /// Corrupting any field the proof commits to must be rejected. `lightning` reports every failed
+ /// cryptographic check as an undifferentiated decode failure, so all of these currently come
+ /// back as [`VerifyError::MalformedProof`].
+ #[test]
+ fn rejects_a_corrupted_field() {
+ let bytes = full_proof_bytes();
+ let cases = [
+ (PROOF_PREIMAGE, VerifyError::MalformedProof),
+ (INVOICE_NODE_ID, VerifyError::MalformedProof),
+ (ISSUER_SIGNATURE, VerifyError::MalformedProof),
+ (PROOF_LEAF_HASHES, VerifyError::MalformedProof),
+ (PROOF_OMITTED_MARKERS, VerifyError::MalformedProof),
+ (PROOF_SIGNATURE, VerifyError::MalformedProof),
+ (PROOF_NOTE, VerifyError::MalformedProof),
+ ];
+
+ for (tlv_type, expected) in cases {
+ let range = tlv_value_range(&bytes, tlv_type);
+ let mut corrupted = bytes.clone();
+ // Flip the last byte of the value: for the pubkey and signature fields an early byte
+ // can instead make the field fail to deserialize, which is a different complaint.
+ corrupted[range.end - 1] ^= 0x01;
+
+ assert_eq!(
+ verify_bytes(&corrupted).unwrap_err(),
+ expected,
+ "corrupting TLV {} was reported as the wrong error",
+ tlv_type
+ );
+ }
+ }
+
+ fn test_offer() -> Offer {
+ let hex = OFFER_HEX.trim();
+ let bytes: Vec<u8> = (0..hex.len())
+ .step_by(2)
+ .map(|i| {
+ u8::from_str_radix(&hex[i..i + 2], 16).expect("offer vector must be valid hex")
+ })
+ .collect();
+ Offer::try_from(bytes).expect("offer vector must parse")
+ }
+
+ #[test]
+ fn checks_the_recipient_of_the_offer_it_was_built_from() {
+ let proof = verify_bytes(&decode_hex(OFFER_PROOF_HEX)).unwrap();
+ assert!(proof.pays_offers_recipient(&test_offer()));
+
+ let other_hex = OTHER_OFFER_HEX.trim();
+ let other_bytes: Vec<u8> = (0..other_hex.len())
+ .step_by(2)
+ .map(|i| {
+ u8::from_str_radix(&other_hex[i..i + 2], 16).expect("vector must be valid hex")
+ })
+ .collect();
+ let someone_else = Offer::try_from(other_bytes).expect("other offer must parse");
+ assert!(!proof.pays_offers_recipient(&someone_else));
+ }
+
+ #[test]
+ fn checks_the_recipient_of_an_offer_in_its_bech32_form() {
+ let proof = verify_bytes(&decode_hex(OFFER_PROOF_HEX)).unwrap();
+ assert!(proof.pays_offers_recipient_str(&test_offer().to_string()));
+ assert!(!proof.pays_offers_recipient_str("not an offer"));
+ assert!(!proof.pays_offers_recipient_str(""));
+ }
+
+ /// Every byte of the stream must be covered by something the proof commits to. Walk all of
+ /// them so no region is left silently unauthenticated.
+ #[test]
+ fn rejects_a_tampered_proof() {
+ let bytes = full_proof_bytes();
+
+ for index in 0..bytes.len() {
+ let mut tampered = bytes.clone();
+ tampered[index] ^= 0x01;
+
+ assert!(
+ verify_bytes(&tampered).is_err(),
+ "flipping byte {} left the proof verifying",
+ index
+ );
+ }
+ }
+}
### lightning-payer-proof/test_vectors/minimal_proof.hex
@@ -0,0 +1 @@
+5821035be5e9478209674a96e60f1f037f6176540fd001fa1d64694770c56a7709c42ca82092b7eb5290d8d6e3ac79215cb4bdb07fe89629ee720be4332b3daa842b7ec80ab02102bb58b5feca505c74edc000d8282fc556e51a1024fc8e7d7e56c6f887c5c8d5f2f04088c2f20145ea72cd12cd52cf1a205363492524001ce1d26c0291b6edc1c2a26002c23e6f928ca37e53456923cc729558a9c16f5c0bac0683b4d8149f2022b98bf14043ab8b453c359f99590674c7f78ae8309f6c68b7232820807d7f9bd1c5620cdd9de57a967e8cc22ae8a908bfddaf2d0c2e793881f70b3a1608db661c03fa140afd03e9204040404040404040404040404040404040404040404040404040404040404040fd03ea060102595a5ba9fd03eba0312a2c8fc5acbc4f5c6ab555003ba5a95536a65bfb360e4878f44186eb98fa2cb3db3c0fe6893f5805978b39b1020ff1572cad47850ed66ba0770143e6b44d4798b0eb4386ca2aef284760cf4d5fab2adcda30b127fc094bec36786c6584dd82c7f1231b965627393f31a28639866d19b6c7adb32b02129f02ced55a85c9e0e77dcca1e74db856d09fd6f0cfb6718928e6b2441dd594fa832b07d0b0c0c4ec5afd03ec60783fa4c49b4adc1b9b01b93bb43eded44df91b50cc387e4ca20f764f533e1fce8c7cb428293a0a333b34a07e062f6d70712bde836117f8a1b7bf783c4c34c1e8b18b704c40832fe52989a30d734aef5569ce26940eaff332c6471e5eba55cb6b
### lightning-payer-proof/test_vectors/offer.hex
@@ -0,0 +1 @@
+0802a4100a0c636f66666565206265616e73162102bb58b5feca505c74edc000d8282fc556e51a1024fc8e7d7e56c6f887c5c8d5f2
### lightning-payer-proof/test_vectors/offer_proof.hex
@@ -0,0 +1 @@
+5821030d070ef4bd15be2425ecc92d29837c49ad9eb2980f569c40d0a829269c91d88aa82092b7eb5290d8d6e3ac79215cb4bdb07fe89629ee720be4332b3daa842b7ec80ab02102bb58b5feca505c74edc000d8282fc556e51a1024fc8e7d7e56c6f887c5c8d5f2f040de5856e00b7c644c77f38f582a49a5cf0944d1ff1d58cfaf8aa6f55bda1ba738167d6fa42bafbf807bc98114ce8cffcdc50c2a2b30ed307a15da12128f06b04ff140f2c871adc31212b1550606d0234ba83905eb85ae5efcdba229609604b034487340e1087445b147cdbfa307b207e604fa06fcf0c93be8bef221ff0a9d116b6d36fd03e9204040404040404040404040404040404040404040404040404040404040404040fd03ea07010203595a5ba9fd03eb80a285d4bbef10335d94814e35492b68ebc5c2203c1f96519e3122aa4d72ed673de61a7954144de65e290d61449ae63095faf4fdb748edc76c8d41a3e65669c73fe16fd158d3b35ee667841ebe002955f305a683953f44752e4af369608e95b2963b5a19f81815e7a7950a7cf7af2f585860e1e3c32cc6cc7cc732335d431bf327fd03ec6023fc62ff1abe0d4ba379a96c388bf407ba00157b03bc593d988b42362c8f68d492d42cb34e647d933c06c0914f645b209e9c085ddf21dcd5e39c05b0449af5dc4aea2c0e7c5b06af946024636569dd14496c05f8ae7b200380bcb6f2e03d1f30
### lightning-payer-proof/test_vectors/other_offer.hex
@@ -0,0 +1 @@
+0802a4100a0c636f66666565206265616e731621035be5e9478209674a96e60f1f037f6176540fd001fa1d64694770c56a7709c42c
### lightning-payer-proof/test_vectors/valid_proof.hex
@@ -0,0 +1 @@
+0a0c636f66666565206265616e73120c4c444b20526f6173746572795821035be5e9478209674a96e60f1f037f6176540fd001fa1d64694770c56a7709c42ca4046553f100a82092b7eb5290d8d6e3ac79215cb4bdb07fe89629ee720be4332b3daa842b7ec80aaa02a410b02102bb58b5feca505c74edc000d8282fc556e51a1024fc8e7d7e56c6f887c5c8d5f2f040849b370e7f64e29ae182d9545effd72f93e7fc33730412d0bd8480dbed587cc4cbe2a145b6bade3db7ee4dd2ca844353d1e240cbd87684d3be0626951d2c8432f140d28370ad218ed06c9ed63226c6d94e48d87da5a6bd0d952672c116a44eb0e22241208f5a82c5b9e6800d6c99fb5c6a7b8e70959d5b236ea155bcb92c12018b34fd03e9204040404040404040404040404040404040404040404040404040404040404040fd03ea0313595afd03eb80f1d107a3eeac28d98bcb79ca0004d86135bf93dbda4d5aa68bd0d389119c59d7312a2c8fc5acbc4f5c6ab555003ba5a95536a65bfb360e4878f44186eb98fa2c7cd7266f82f25544e872a81b8569040f71ac6d60238a940b3b4ccced7132b3903a1470f1cafc8ec68814e2a95cf4841cd55ad26a2c63f3810d82fbd39e0348dafd03ece04951b42a8a769f98a7209ca2391f8bf687fdd2e895e490d8ea249650560d313a939629804d55c7658de7b07d2228bfc8ef0bd06a8d69e5c276190af63d4f0a5b783fa4c49b4adc1b9b01b93bb43eded44df91b50cc387e4ca20f764f533e1fce55e4185c852f817fc1640827878d429622f32831772223c3121f8474cd05fbb08c7cb428293a0a333b34a07e062f6d70712bde836117f8a1b7bf783c4c34c1e836b3cfe32413af1c28a9461c302d431d9a056f24751e58f0527bc44965ca5599b18b704c40832fe52989a30d734aef5569ce26940eaff332c6471e5eba55cb6bfd03ed0a6f726465722d31323334
### lightning-payer-proof/uniffi/Cargo.toml
@@ -0,0 +1,38 @@
+# Its own workspace: `uniffi`'s MSRV is well above the rest of the tree's, so
+# this crate is built and tested on its own rather than as a workspace member.
+[workspace]
+
+[package]
+name = "lightning-payer-proof-uniffi"
+version = "0.1.0+git"
+authors = ["Vincenzo Palazzo <vincenzopalazzodev@gmail.com>", "Matt Corallo"]
+license = "MIT OR Apache-2.0"
+repository = "https://git.rust-bitcoin.org/lightningdevkit/rust-lightning"
+description = """
+UniFFI bindings for verification of BOLT 12 payer proofs.
+"""
+edition = "2021"
+build = "build.rs"
+
+[dependencies]
+lightning-payer-proof = { version = "0.1.0", path = "../" }
+uniffi = { version = "0.27", default-features = false, features = ["cli"] }
+
+[build-dependencies]
+uniffi = { version = "0.27", features = [ "build" ] }
+
+[[bin]]
+name = "uniffi-bindgen"
+path = "uniffi-bindgen.rs"
+
+[lib]
+# uniffi's generated bindings look for `uniffi_<namespace>`, so name the
+# library to match rather than making every consumer rename it.
+name = "uniffi_lightning_payer_proof"
+# `lib` alongside `cdylib` so the wrappers can be unit tested; the `cdylib` is
+# what bindings consumers actually load.
+crate-type = ["lib", "cdylib"]
+
+[profile.release]
+lto = true
+codegen-units = 1
### lightning-payer-proof/uniffi/README.md
@@ -0,0 +1,26 @@
+# lightning-payer-proof-uniffi
+
+UniFFI bindings for [`lightning-payer-proof`](../), exposing `verify`, `verify_bytes` and
+`pays_offers_recipient` to Kotlin, Swift, Python and the other languages uniffi targets.
+
+## Building
+
+This is its own workspace rather than a member of the one at the repository root: `uniffi` pulls in
+a dependency tree well above LDK's 1.75 MSRV, so it is built with a current toolchain and is not
+covered by the MSRV or `no_std` CI jobs.
+
+```
+cargo build --release
+cargo run --bin uniffi-bindgen -- generate src/interface.udl --language kotlin --out-dir bindings
+```
+
+Substitute `swift`, `python` or `ruby` for `kotlin`. The generated code loads
+`libuniffi_lightning_payer_proof.{so,dylib,dll}` from alongside itself, so copy the built library
+into the output directory before running it.
+
+## Shape of the API
+
+`VerifiedPayerProof` is a flat record of owned plain values rather than a struct with accessors,
+which is what uniffi can carry across the FFI. Keys, hashes and signatures are byte arrays in their
+usual wire encodings: 33 bytes for a compressed public key, 32 for a hash or preimage, 64 for a
+BIP 340 signature. Fields the payer withheld are absent rather than empty.
### lightning-payer-proof/uniffi/build.rs
@@ -0,0 +1,3 @@
+fn main() {
+ uniffi::generate_scaffolding("src/interface.udl").unwrap();
+}
### lightning-payer-proof/uniffi/src/interface.udl
@@ -0,0 +1,33 @@
+[Error]
+enum VerifyError {
+ "InvalidBech32",
+ "MalformedProof",
+ "IncompleteProof",
+};
+
+dictionary VerifiedPayerProof {
+ bytes payment_hash;
+ bytes payment_preimage;
+ bytes payer_signing_pubkey;
+ bytes issuer_signing_pubkey;
+ u64? invoice_amount_msats;
+ u64? invoice_created_at_secs;
+ string? offer_description;
+ string? offer_issuer;
+ string? proof_note;
+ bytes merkle_root;
+ bytes invoice_signature;
+ bytes proof_signature;
+ string bech32;
+ bytes encoded;
+};
+
+namespace lightning_payer_proof {
+ [Throws=VerifyError]
+ VerifiedPayerProof verify(string proof);
+
+ [Throws=VerifyError]
+ VerifiedPayerProof verify_bytes(bytes proof);
+
+ boolean pays_offers_recipient(VerifiedPayerProof proof, string offer);
+};
### lightning-payer-proof/uniffi/src/lib.rs
@@ -0,0 +1,228 @@
+// This file is Copyright its original authors, visible in version control
+// history.
+//
+// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE
+// or http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
+// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your option.
+// You may not use this file except in accordance with one or both of these
+// licenses.
+
+//! UniFFI-compatible payer proof verification wrappers.
+
+// Note this crate cannot `forbid(unsafe_code)` the way `lightning-payer-proof` does: the
+// scaffolding uniffi generates from `interface.udl` is the FFI entry point and exports
+// `#[no_mangle]` symbols. Nothing hand-written here is unsafe.
+#![deny(missing_docs)]
+#![deny(rustdoc::broken_intra_doc_links)]
+
+uniffi::include_scaffolding!("interface");
+
+/// Why a payer proof was rejected.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum VerifyError {
+ /// The string is not a bech32-encoded payer proof, or carries a prefix other than `lnp`.
+ InvalidBech32,
+ /// The bytes decoded as bech32 but are not a well-formed payer proof TLV stream.
+ MalformedProof,
+ /// The TLV stream is well-formed but omits a field required to verify the proof at all.
+ IncompleteProof,
+}
+
+impl core::fmt::Display for VerifyError {
+ fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
+ f.write_str(match self {
+ VerifyError::InvalidBech32 => "not a bech32-encoded payer proof",
+ VerifyError::MalformedProof => "malformed payer proof",
+ VerifyError::IncompleteProof => "payer proof is missing a required field",
+ })
+ }
+}
+
+impl std::error::Error for VerifyError {}
+
+impl From<lightning_payer_proof::VerifyError> for VerifyError {
+ fn from(error: lightning_payer_proof::VerifyError) -> Self {
+ match error {
+ lightning_payer_proof::VerifyError::InvalidBech32 => VerifyError::InvalidBech32,
+ lightning_payer_proof::VerifyError::MalformedProof => VerifyError::MalformedProof,
+ lightning_payer_proof::VerifyError::IncompleteProof => VerifyError::IncompleteProof,
+ }
+ }
+}
+
+/// A payer proof that passed every check in [`verify`].
+///
+/// This proves the preimage hashes to the payment hash, the issuer signed the disclosed fields,
+/// and the payer signed this proof. It does not prove they paid the invoice you have in mind.
+/// Anyone can issue an invoice to themselves, pay it, and hand out a proof that verifies. Check
+/// the proof against the offer with [`pays_offers_recipient`].
+///
+/// Keys, hashes and signatures are wire-encoded (33-byte compressed pubkeys, 32-byte hashes or
+/// preimages, 64-byte BIP 340 signatures). Withheld fields are `None`.
+#[derive(Clone, Debug)]
+pub struct VerifiedPayerProof {
+ /// The payment hash this proof settles, 32 bytes.
+ pub payment_hash: Vec<u8>,
+ /// The preimage that unlocked the payment, 32 bytes, proven to hash to `payment_hash`.
+ pub payment_preimage: Vec<u8>,
+ /// The compressed public key the payer signed this proof with, 33 bytes.
+ pub payer_signing_pubkey: Vec<u8>,
+ /// The compressed public key the invoice was signed with, 33 bytes, identifying who issued it.
+ pub issuer_signing_pubkey: Vec<u8>,
+ /// The invoiced amount in millisatoshis, if disclosed.
+ pub invoice_amount_msats: Option<u64>,
+ /// When the invoice was created, in seconds since the Unix epoch, if disclosed.
+ pub invoice_created_at_secs: Option<u64>,
+ /// The offer description the invoice was built from, if disclosed.
+ ///
+ /// Untrusted text; sanitize control characters before displaying.
+ pub offer_description: Option<String>,
+ /// The offer issuer, if disclosed.
+ ///
+ /// A human-readable label chosen by whoever built the offer, not an identity.
+ pub offer_issuer: Option<String>,
+ /// A note the payer attached when building the proof, if any.
+ pub proof_note: Option<String>,
+ /// The merkle root of the invoice the issuer signed, 32 bytes.
+ pub merkle_root: Vec<u8>,
+ /// The issuer's signature over the invoice, 64 bytes in BIP 340 form.
+ pub invoice_signature: Vec<u8>,
+ /// The payer's signature over this proof, 64 bytes in BIP 340 form.
+ pub proof_signature: Vec<u8>,
+ /// The proof re-encoded as the `lnp1...` string it was parsed from.
+ pub bech32: String,
+ /// The proof's raw TLV stream, as accepted by [`verify_bytes`].
+ pub encoded: Vec<u8>,
+}
+
+impl From<lightning_payer_proof::VerifiedPayerProof> for VerifiedPayerProof {
+ fn from(proof: lightning_payer_proof::VerifiedPayerProof) -> Self {
+ Self {
+ payment_hash: proof.payment_hash().0.to_vec(),
+ payment_preimage: proof.payment_preimage().0.to_vec(),
+ payer_signing_pubkey: proof.payer_signing_pubkey().serialize().to_vec(),
+ issuer_signing_pubkey: proof.issuer_signing_pubkey().serialize().to_vec(),
+ invoice_amount_msats: proof.invoice_amount_msats(),
+ invoice_created_at_secs: proof.invoice_created_at_secs(),
+ offer_description: proof.offer_description().map(|text| text.0),
+ offer_issuer: proof.offer_issuer().map(|text| text.0),
+ proof_note: proof.proof_note().map(|text| text.0),
+ merkle_root: proof.merkle_root().to_vec(),
+ invoice_signature: proof.invoice_signature().to_vec(),
+ proof_signature: proof.proof_signature().to_vec(),
+ bech32: proof.to_bech32(),
+ encoded: proof.encode(),
+ }
+ }
+}
+
+/// Verifies a bech32-encoded (`lnp1...`) payer proof.
+///
+/// Uppercase and BOLT 12 `+` line-splitting are accepted.
+pub fn verify(proof: String) -> Result<VerifiedPayerProof, VerifyError> {
+ lightning_payer_proof::verify(&proof).map(Into::into).map_err(Into::into)
+}
+
+/// Verifies a payer proof from its raw TLV bytes, skipping bech32.
+pub fn verify_bytes(proof: Vec<u8>) -> Result<VerifiedPayerProof, VerifyError> {
+ lightning_payer_proof::verify_bytes(&proof).map(Into::into).map_err(Into::into)
+}
+
+/// Whether this already-verified `proof` was issued by the recipient of `offer` (`lno1...`).
+///
+/// A `true` identifies the recipient, not the offer. A garbage offer is `false`.
+pub fn pays_offers_recipient(proof: VerifiedPayerProof, offer: String) -> bool {
+ lightning_payer_proof::verify_bytes(&proof.encoded)
+ .map(|proof| proof.pays_offers_recipient_str(&offer))
+ .unwrap_or(false)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// The same proof `lightning-payer-proof`'s own tests use: a 42,000 msat invoice created at
+ /// Unix time 1,700,000,000, disclosing the description, issuer, amount and timestamp, with the
+ /// payer note "order-1234".
+ const FULL_PROOF_HEX: &str = include_str!("../../test_vectors/valid_proof.hex");
+
+ /// The same invoice with none of the optional fields disclosed and no note.
+ const MINIMAL_PROOF_HEX: &str = include_str!("../../test_vectors/minimal_proof.hex");
+
+ /// An offer, a proof over an invoice built from it, and a second offer that differs only in
+ /// issuer id.
+ const OFFER_HEX: &str = include_str!("../../test_vectors/offer.hex");
+ const OFFER_PROOF_HEX: &str = include_str!("../../test_vectors/offer_proof.hex");
+ const OTHER_OFFER_HEX: &str = include_str!("../../test_vectors/other_offer.hex");
+
+ fn decode_hex(hex: &str) -> Vec<u8> {
+ let hex = hex.trim();
+ (0..hex.len())
+ .step_by(2)
+ .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("vector must be valid hex"))
+ .collect()
+ }
+
+ /// Every field must survive the flattening at the width the wire encoding gives it, since a
+ /// caller on the other side of the FFI has only these bytes to work with.
+ #[test]
+ fn flattens_a_verified_proof() {
+ let proof = verify_bytes(decode_hex(FULL_PROOF_HEX)).unwrap();
+
+ assert_eq!(proof.payment_hash.len(), 32);
+ assert_eq!(proof.payment_preimage.len(), 32);
+ assert_eq!(proof.merkle_root.len(), 32);
+ assert_eq!(proof.payer_signing_pubkey.len(), 33);
+ assert_eq!(proof.issuer_signing_pubkey.len(), 33);
+ assert_eq!(proof.invoice_signature.len(), 64);
+ assert_eq!(proof.proof_signature.len(), 64);
+ assert_ne!(proof.payer_signing_pubkey, proof.issuer_signing_pubkey);
+
+ assert_eq!(proof.invoice_amount_msats, Some(42_000));
+ assert_eq!(proof.invoice_created_at_secs, Some(1_700_000_000));
+ assert_eq!(proof.offer_description.as_deref(), Some("coffee beans"));
+ assert_eq!(proof.offer_issuer.as_deref(), Some("LDK Roastery"));
+ assert_eq!(proof.proof_note.as_deref(), Some("order-1234"));
+
+ assert!(proof.bech32.starts_with("lnp1"), "unexpected prefix: {}", proof.bech32);
+ assert_eq!(verify(proof.bech32.clone()).unwrap().encoded, proof.encoded);
+ }
+
+ /// Withheld fields must arrive as `None`, not as empty strings or zeroes, or a caller cannot
+ /// tell "not disclosed" from "disclosed as nothing".
+ #[test]
+ fn withheld_fields_are_none() {
+ let proof = verify_bytes(decode_hex(MINIMAL_PROOF_HEX)).unwrap();
+
+ assert_eq!(proof.invoice_amount_msats, None);
+ assert_eq!(proof.invoice_created_at_secs, None);
+ assert_eq!(proof.offer_description, None);
+ assert_eq!(proof.offer_issuer, None);
+ assert_eq!(proof.proof_note, None);
+ }
+
+ /// The whole point of mirroring the error enum is that the specific check still comes through.
+ #[test]
+ fn maps_errors_to_their_own_variants() {
+ assert_eq!(verify(String::new()).unwrap_err(), VerifyError::InvalidBech32);
+ assert_eq!(verify("not a proof".to_string()).unwrap_err(), VerifyError::InvalidBech32);
+
+ let mut corrupted = decode_hex(FULL_PROOF_HEX);
+ let last = corrupted.len() - 1;
+ corrupted[last] ^= 0x01;
+ assert_eq!(verify_bytes(corrupted).unwrap_err(), VerifyError::MalformedProof);
+ }
+
+ fn offer_bech32(hex: &str) -> String {
+ let bytes = decode_hex(hex);
+ lightning_payer_proof::Offer::try_from(bytes).expect("offer vector must parse").to_string()
+ }
+
+ #[test]
+ fn checks_the_recipient_of_a_verified_proof() {
+ let proof = verify_bytes(decode_hex(OFFER_PROOF_HEX)).unwrap();
+ assert!(pays_offers_recipient(proof.clone(), offer_bech32(OFFER_HEX)));
+ assert!(!pays_offers_recipient(proof.clone(), offer_bech32(OTHER_OFFER_HEX)));
+ assert!(!pays_offers_recipient(proof, "not an offer".to_string()));
+ }
+}
### lightning-payer-proof/uniffi/uniffi-bindgen.rs
@@ -0,0 +1,3 @@
+fn main() {
+ uniffi::uniffi_bindgen_main()
+}
### msrv-no-dev-deps-check/Cargo.toml
@@ -7,6 +7,7 @@ edition = "2021"
lightning = { path = "../lightning" }
lightning-block-sync = { path = "../lightning-block-sync", features = [ "rest-client", "rpc-client" ] }
lightning-invoice = { path = "../lightning-invoice" }
+lightning-payer-proof = { path = "../lightning-payer-proof" }
lightning-net-tokio = { path = "../lightning-net-tokio" }
lightning-persister = { path = "../lightning-persister" }
lightning-background-processor = { path = "../lightning-background-processor" }
### no-std-check/Cargo.toml
@@ -9,5 +9,6 @@ default = []
[dependencies]
lightning = { path = "../lightning", default-features = false }
lightning-invoice = { path = "../lightning-invoice", default-features = false }
+lightning-payer-proof = { path = "../lightning-payer-proof", default-features = false }
lightning-rapid-gossip-sync = { path = "../lightning-rapid-gossip-sync", default-features = false }
lightning-background-processor = { path = "../lightning-background-processor", default-features = false }
### pending_changelog/payer-proof-uniffi.txt
@@ -0,0 +1,3 @@
+# API Updates
+ * A new `lightning-payer-proof-uniffi` crate exposes the same API to the languages uniffi
+ targets.Why this scored 17/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.