Skip to content

Enhancement: Add Recipient-Signed AsyncHashPool Proofs for Trustless Lightning Address Async Payments #44

@gofman8

Description

@gofman8

Enhancement: Add Recipient-Signed AsyncHashPool Proofs for Trustless Lightning Address Async Payments

Background

The current RLN async payment design uses a stable Lightning Address as the public payment surface.

Today, the invoice-host / LSP owns the AsyncHashPool after async_order.new, reserves a hash_index, creates a fresh bolt11_hodl_invoice, and returns it to the payer through the Lightning Address / LNURL callback.

The intended noncustodial property is:

The invoice-host can only claim the inbound HODL payment after the recipient reveals the matching preimage through the outbound leg.

However, the current design still requires the payer to trust that the payment_hash inside the returned HODL invoice was actually generated by the recipient.

Problem

When a payer resolves a Lightning Address, the Lightning Address server / invoice-host returns a BOLT11 HODL invoice.

That invoice is signed by the LSP node, not by the final recipient.

Because of that, the payer cannot independently verify that the invoice's payment_hash belongs to a preimage controlled by the recipient.

A malicious or compromised invoice-host could return:

  1. A regular invoice controlled by the LSP.
  2. A HODL invoice whose preimage is already known by the LSP.
  3. A HODL invoice using a payment_hash that is not part of the recipient-provided AsyncHashPool.

In all cases, the payer may believe they are paying the recipient, while the LSP may be able to claim the inbound payment without recipient participation.

This creates a trust gap in the current async Lightning Address design.

Goal

Add a cryptographic proof that allows the payer to verify that the returned HODL invoice is bound to:

  1. The intended recipient identity.
  2. A recipient-generated payment_hash.
  3. A recipient-signed batch commitment.
  4. The specific hash_index selected by the invoice-host.
  5. The returned BOLT11 HODL invoice.

After this enhancement, the payer should be able to verify that the invoice-host cannot claim the payment unless the recipient eventually reveals the matching preimage.

Proposed Solution

Introduce Recipient-Signed AsyncHashPool Commitments.

Instead of trusting the payment_hash inside the LSP-signed BOLT11 invoice, the Lightning Address / LNURL callback should return:

  1. The BOLT11 HODL invoice.
  2. A payer-verifiable proof object.

Example response:

{
  "pr": "<bolt11_hodl_invoice>",
  "routes": [],
  "verify": {
    "version": 1,
    "receiver_pubkey": "<recipient_node_pubkey_or_account_pubkey>",
    "order_id": "<order_id_or_public_order_commitment>",
    "batch_id": "<batch_id>",
    "hash_index": 42,
    "payment_hash": "<32-byte-payment-hash>",
    "batch_root": "<merkle_root>",
    "merkle_proof": [
      "<sibling_hash_1>",
      "<sibling_hash_2>"
    ],
    "signature": "<recipient_signature_over_batch_commitment>",
    "signature_scheme": "schnorr/secp256k1"
  }
}

The payer client must verify this proof before paying.

Recipient Identity

The sender must know which recipient public key they are paying.

Option A: Use the recipient public key in the Lightning Address

Example:

<receiver_pubkey>@utexo.com

This gives the simplest trust model because the payer directly sees the recipient identity in the payment address.

Option B: Use a short alias that resolves to the full recipient public key

Example:

alice@utexo.com

In this case, the LNURL metadata or verification object must return the full receiver_pubkey.

The payer must verify that the alias is bound to that recipient public key.

Important:

A short alias or shortened public key should not be treated as the cryptographic identity unless it has enough collision resistance.

For UX, we can use aliases. For security, verification must use the full recipient public key or a sufficiently long collision-resistant fingerprint / commitment.

Batch Commitment Design

When the recipient creates or refreshes an async order, the recipient generates a batch of future preimages:

preimage_i
payment_hash_i = SHA256(preimage_i)

For each hash entry, define a canonical Merkle leaf:

leaf_i = SHA256(
  "UTEXO_ASYNC_HASH_V1" ||
  receiver_pubkey ||
  order_id ||
  batch_id ||
  hash_index_i ||
  payment_hash_i
)

Then build a Merkle tree:

batch_root = merkle_root(
  leaf_0,
  leaf_1,
  ...,
  leaf_n
)

The recipient signs the batch commitment once:

signed_message = SHA256(
  "UTEXO_ASYNC_HASH_BATCH_V1" ||
  receiver_pubkey ||
  order_id ||
  batch_id ||
  batch_root ||
  batch_size ||
  created_at ||
  optional_expiry
)

signature = sign(receiver_private_key, signed_message)

The recipient sends the following to the invoice-host during async_order.new or async_order.sync_hashes:

{
  "order_id": "...",
  "batch_id": "...",
  "receiver_pubkey": "...",
  "batch_root": "...",
  "batch_size": 200,
  "signature": "...",
  "signature_scheme": "schnorr/secp256k1",
  "created_at": "...",
  "optional_expiry": "...",
  "hash_entries": [
    {
      "hash_index": 0,
      "payment_hash": "..."
    },
    {
      "hash_index": 1,
      "payment_hash": "..."
    }
  ]
}

The invoice-host stores:

  • The hash entries.
  • The batch root.
  • The recipient signature.
  • The batch metadata.

Payment Flow With Verification

  1. Recipient-client calls async_order.new.
  2. Recipient-client uploads AsyncHashPool entries plus one signed batch commitment.
  3. Invoice-host stores the batch.
  4. Payer resolves the recipient's Lightning Address.
  5. Invoice-host reserves the next hash_index.
  6. Invoice-host creates a BOLT11 HODL invoice using the corresponding payment_hash.
  7. Invoice-host returns the BOLT11 invoice plus the verification proof object.
  8. Payer parses the BOLT11 invoice and extracts the invoice payment_hash.
  9. Payer verifies:
    • The BOLT11 invoice payment_hash equals verify.payment_hash.
    • The Merkle proof proves that payment_hash is included in batch_root.
    • The batch signature is valid under receiver_pubkey.
    • The receiver_pubkey matches the intended recipient identity.
    • The order_id, batch_id, and hash_index are domain-bound and canonical.
  10. Only if verification succeeds, the payer pays the invoice.

Payer Verification Algorithm

invoice_hash = parse_payment_hash(bolt11_hodl_invoice)

assert invoice_hash == verify.payment_hash

leaf = SHA256(
  "UTEXO_ASYNC_HASH_V1" ||
  verify.receiver_pubkey ||
  verify.order_id ||
  verify.batch_id ||
  verify.hash_index ||
  verify.payment_hash
)

computed_root = verify_merkle_proof(
  leaf,
  verify.merkle_proof
)

assert computed_root == verify.batch_root

signed_message = SHA256(
  "UTEXO_ASYNC_HASH_BATCH_V1" ||
  verify.receiver_pubkey ||
  verify.order_id ||
  verify.batch_id ||
  verify.batch_root ||
  verify.batch_size ||
  verify.created_at ||
  verify.optional_expiry
)

assert verify_signature(
  verify.receiver_pubkey,
  signed_message,
  verify.signature
)

assert verify.receiver_pubkey == expected_receiver_pubkey

If any check fails, the payer must reject the invoice and must not pay.

Why Merkle Proof Instead of Individual Signatures?

The naive design would require the recipient to sign every payment_hash individually:

signature_i = sign(receiver_private_key, payment_hash_i)

This works, but it does not scale well for large batches.

The Merkle design gives us:

  • One recipient signature per batch.
  • One compact inclusion proof per invoice.
  • Efficient payer-side verification.
  • No need for the payer to download the full batch.
  • Cryptographic binding between the recipient identity and each payment hash.

For example, with a batch of 200 entries, the proof requires only around log2(200) sibling hashes instead of 200 individual signatures.

Required Changes

Recipient-client

  • Generate async payment preimages.
  • Derive payment_hash = SHA256(preimage).
  • Build canonical Merkle leaves.
  • Build batch_root.
  • Sign the batch commitment.
  • Send batch_root, signature, and hash entries to invoice-host in async_order.new.
  • Later support the same format in async_order.sync_hashes.

Invoice-host / LSP

  • Store batch metadata:
    • receiver_pubkey
    • batch_id
    • batch_root
    • batch_size
    • batch_signature
    • hash_index
    • payment_hash
  • When creating a HODL invoice, include the selected payment_hash.
  • Return the BOLT11 invoice together with the proof object.
  • Ensure the proof corresponds exactly to the selected hash_index.
  • Never return an invoice without a valid proof for this async payment path.

Payer-client

  • Parse the BOLT11 invoice.
  • Extract payment_hash.
  • Verify the proof object.
  • Reject invoices without a valid proof when paying a verified async Lightning Address.
  • Only pay if the HODL invoice is cryptographically bound to the intended recipient.

Data Model Additions

New table: AsyncHashBatch

AsyncHashBatch
- order_id
- batch_id
- receiver_pubkey
- batch_root
- batch_size
- signature
- signature_scheme
- created_at
- expires_at optional

Extend AsyncHashPool

AsyncHashPool
- order_id
- batch_id
- hash_index
- payment_hash
- merkle_proof optional/cached
- status

Extend Lightning Address callback response

AsyncInvoiceProof
- version
- receiver_pubkey
- order_id or public_order_commitment
- batch_id
- hash_index
- payment_hash
- batch_root
- batch_size
- merkle_proof
- signature
- signature_scheme
- created_at
- optional_expiry

Security Properties

After this enhancement:

  • The LSP cannot replace the payment hash with one it controls without invalidating the proof.
  • The LSP cannot return a regular invoice controlled by itself and make it pass recipient verification.
  • The payer can verify that the invoice's payment hash belongs to a recipient-signed batch.
  • The recipient does not need to be online at invoice-fetch time.
  • The invoice-host still performs the always-online Lightning Address role.
  • The invoice-host no longer needs to be trusted for payment-hash correctness.

Open Questions

  1. Should the public Lightning Address localpart be the full receiver_pubkey, or should we support aliases that resolve to a full recipient public key?
  2. If we use shortened identifiers, what minimum collision-resistant size is acceptable?
  3. Should order_id be exposed directly in the proof, or should we use a public_order_commitment to avoid leaking internal IDs?
  4. Should batch commitments have explicit expiry?
  5. Should the signature key be the RLN node pubkey, a wallet account key, or a dedicated async-payment identity key?
  6. Should the Merkle proof be generated dynamically by the invoice-host, or precomputed and stored per hash_index?
  7. Should the payer require this proof only for async Lightning Address payments, or for all LSP-hosted HODL invoices?
  8. Should the LNURL metadata also commit to receiver_pubkey, so the payer can bind the human-readable Lightning Address to the cryptographic recipient identity?

Acceptance Criteria

  • Recipient-client can create a signed AsyncHashPool batch.
  • Invoice-host stores the batch commitment and recipient signature.
  • Invoice-host returns bolt11_hodl_invoice plus proof object in the Lightning Address callback response.
  • Payer-client verifies the proof before paying.
  • Payer rejects invoices where the BOLT11 payment_hash does not match the proven payment_hash.
  • Payer rejects invoices where the Merkle proof is invalid.
  • Payer rejects invoices where the batch signature is invalid.
  • Payer rejects invoices where receiver_pubkey does not match the intended recipient.
  • Tests cover malicious invoice-host substitution with an LSP-controlled payment hash.
  • Tests cover regular successful async payment flow.
  • Tests cover malformed proof, wrong hash_index, wrong batch_id, wrong receiver_pubkey, and wrong invoice hash.

Suggested Priority

High.

This closes a major trust gap in the current async Lightning Address design and makes the payer-side security model match the intended noncustodial settlement model.

Metadata

Metadata

Assignees

Labels

Type

No type
No fields configured for issues without a type.

Projects

Status

Todo

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions