Skip to content

feat: pay Nostr Offer Strings (CLINK)#4102

Open
kaloudis wants to merge 12 commits into
ZeusLN:masterfrom
kaloudis:clink
Open

feat: pay Nostr Offer Strings (CLINK)#4102
kaloudis wants to merge 12 commits into
ZeusLN:masterfrom
kaloudis:clink

Conversation

@kaloudis
Copy link
Copy Markdown
Contributor

@kaloudis kaloudis commented May 17, 2026

Description

Relates to issue: ZEUS-2400

Adds support for CLINK — pay-to-noffer over Nostr. CLINK is a successor to LNURL-pay and Lightning Addresses that uses Nostr direct messages as transport, eliminating the need for a publicly accessible HTTPS endpoint on the recipient side.

How it works

A noffer1... bech32 string carries the recipient's Nostr pubkey, a recommended relay URL, and an offer id (plus optional pricing hints). When the user scans or pastes one:

  1. Decoded TLV → dedicated ClinkPay view (mirrors the LnurlPay pattern).
  2. Wallet generates an ephemeral payer keypair, NIP-44-encrypts a request payload, and publishes a kind-21001 event to the recommended relay.
  3. Recipient service responds with a bolt11 invoice (or a structured error code).
  4. Wallet sanity-checks that the invoice amount matches the offer, then hands off to the existing PaymentRequest flow.

Entry points

  • Standalone noffer1... (scan / paste).
  • BIP-21 with ?noffer=noffer1... (collision case routes through ChoosePaymentMethod, which now shows a CLINK row).
  • Contacts: add a noffer to a contact and tap to pay — works from ContactDetails, the Contacts settings list, and the Send-screen contact picker.

Spec compliance

  • Pricing types: fixed, variable (with optional currency code), spontaneous — all handled.
  • Mandatory clink_version: "1" tag on every request and response; responses lacking it are rejected.
  • Response signature is verified (verifySignature) and the author is checked against the offer's recipient pubkey before decryption.
  • Service-returned invoice amount is verified against either the fixed offer price or the user-requested amount; mismatches surface a clear error and refuse to proceed.
  • Ephemeral key per request (privacy: payments aren't linked to a user identity).
  • Error codes 1–5 (Invalid Offer / Temporary Failure / Expired or Moved / Unsupported Feature / Invalid Amount) surfaced as localized messages.

Out of scope / follow-up

  • .onion relays: refused with a clear error rather than silently leaked over clearnet. WebSocket-over-Tor would be a separate change.
  • CLINK Debits (ndebit1) and CLINK Manage (nmanage1): tracked separately; this PR is offers-only.
  • NIP-05 → CLINK discovery for Lightning Addresses: spec doesn't document the advertisement field yet.
  • Receipt event ({res:"ok", preimage}): redundant with the LN backend's own preimage; not wired.

PR Type

This pull request is categorized as a:

  • New feature
  • Bug fix
  • Code refactor
  • Configuration change
  • Locales update
  • Quality assurance
  • Other

Checklist

  • I've run yarn run tsc and made sure my code compiles correctly
  • I've run yarn run lint and made sure my code didn't contain any problematic patterns
  • I've run yarn run prettier and made sure my code is formatted correctly
  • I've run yarn run test and made sure all of the tests pass

Testing

If you modified or added a utility file, did you add new unit tests?

  • No, I'm a fool
  • Yes
  • N/A

utils/ClinkUtils.test.ts covers the bech32/TLV decoder (all three pricing types, multi-byte prices, uppercase, malformed input, bad checksum, missing required TLVs, wrong prefix), the request-payload builder (amount-required enforcement for spontaneous/variable), the response-event structural validator (wrong kind, wrong author, missing clink_version, wrong e tag), and verifyBolt11MatchesOffer against signed bolt11 test vectors (fixed match/mismatch, spontaneous match/mismatch, variable match, amountless-allowed-for-spontaneous, amountless-rejected-otherwise, undecodable input). utils/AddressUtils.test.ts covers the noffer= BIP-21 parameter parsing.

I have tested this PR on the following platforms (please specify OS version and phone model/VM):

  • Android
  • iOS

I have tested this PR with the following types of nodes (please specify node version and API version where appropriate):

On-device

  • LDK Node
  • Embedded LND

Remote

  • LND (REST)
  • LND (Lightning Node Connect)
  • Core Lightning (CLNRest)
  • Nostr Wallet Connect
  • LndHub

Locales

  • I've added new locale text that requires translations
  • I'm aware that new translations should be made on the ZEUS Transifex page and not directly to this repo

New keys: views.ClinkPay.* (14), views.Settings.Noffer, utils.handleAnything.invalidNoffer.

Third Party Dependencies and Packages

  • Contributors will need to run yarn after this PR is merged in
  • 3rd party dependencies have been modified

No new dependencies — uses nostr-tools (1.16.0), @nostr/tools/nip44, @scure/base, and bolt11, all already in package.json.

@kaloudis kaloudis added the Nostr label May 17, 2026
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for CLINK (Nostr-based Lightning offers), adding a new payment flow, utility functions for offer decoding, and integration into the contact and URI handling systems. The reviewer suggested an improvement to the decodeNoffer utility to ensure full compliance with the CLINK specification by correctly handling default price types when the TLV field is omitted.

Comment thread utils/ClinkUtils.ts
@kaloudis kaloudis added this to the v13.1.0 milestone May 18, 2026
@shocknet-justin
Copy link
Copy Markdown

  • NIP-05 → CLINK discovery for Lightning Addresses: spec doesn't document the advertisement field yet.

A CLINK offer is discovered from the NIP-05 side (not the reverse) with clink_offer added to the NIP-05 response, such that the wallet can bypass the LNURL lookup and go over Nostr directly

Example NIP-05 Response:

{
  "names": {
    "bob": "<hex_pub>"
  },
  "clink_offer": {
    "bob": "noffer1..."
  }
}

Comment thread locales/en.json Outdated
@kaloudis kaloudis modified the milestones: v13.1.0, v13.2.0 May 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants