Skip to content

feat(lnurl): LUD-21 verify on the LNURL-pay proxy callback#440

Merged
islandbitcoin merged 3 commits into
mainfrom
jabariennis/lud21-lnurl-verify
Jul 7, 2026
Merged

feat(lnurl): LUD-21 verify on the LNURL-pay proxy callback#440
islandbitcoin merged 3 commits into
mainfrom
jabariennis/lud21-lnurl-verify

Conversation

@islandbitcoin

Copy link
Copy Markdown
Contributor

Summary

Adds LUD-21 payment verification to the public LNURL-pay proxy (ibex.flashapp.me). The callback response now carries a verify URL, and the new GET /pay/lnurl/verify/:paymentHash reports settlement ({status, settled, preimage, pr}) backed by IBEX invoice-from-hash (already wrapped in client.ts).

Payers can now confirm settlement programmatically — the immediate consumer is Hustle Link's WhatsApp agent (auto-announcing "⚡ Paid" instead of the vendor eyeballing a push notification), but it benefits every integrator on Flash lightning addresses.

Design notes:

  • settled = IBEX state SETTLED or settleDateUtc present; preimage only revealed once settled
  • Strict 64-hex hash validation before any IBEX call; unknown/malformed/errored all map to LUD-21's 404 {status:"ERROR", reason:"Not found"}
  • Permissive CORS on verify only (hash = unguessable capability; web wallets poll cross-origin); same public rate limit as the callback

Test plan

  • 5 unit tests: settled, open, unknown-hash 404, malformed-hash short-circuit, verify-URL construction
  • All webhook-server suites green; tsc + eslint clean
  • Manual TEST-env check after deploy: pay a user@flashapp.me invoice, poll the verify URL through settlement

🤖 Generated with Claude Code

The public GET /pay/lnurl/:username callback returned {pr} with no way
for the payer to confirm settlement — integrators (e.g. Hustle Link)
had to rely on the vendor noticing a push notification. LUD-21 closes
that loop:

- Callback response now includes verify:
  <webhook-base>/pay/lnurl/verify/<paymentHash>
- New public GET /pay/lnurl/verify/:paymentHash returns
  {status, settled, preimage, pr} per LUD-21, backed by IBEX
  invoice-from-hash. settled = state SETTLED or settleDateUtc present;
  preimage only revealed once settled.
- Unknown/malformed hashes (strict 64-hex validation, checked before
  any IBEX call) and IBEX errors all map to LUD-21's
  404 {status:"ERROR", reason:"Not found"}.
- Permissive CORS on verify only — the payment hash is an unguessable
  capability and web wallets poll cross-origin. Same public rate limit
  as the callback.

Benefits every app building on Flash lightning addresses.

5 tests; webhook-server suites green.
router.get(
paths.verify,
publicLnurlRateLimit,
cors({ origin: true, methods: ["GET"] }),
…dening

Addresses the adversarial review:

B2 (security): verify previously answered for ANY Flash/IBEX invoice via
the org-scoped invoice-from-hash — disclosing bolt11 (amount, memo),
settlement timing, and post-settlement preimages for invoices this proxy
never issued, on the false premise that payment hashes are unguessable
(routing nodes and anyone shown an invoice see them; zap receipts embed
bolt11s publicly). Issued hashes are now recorded in LnurlInvoiceModel
(unique index, 30d TTL) at callback time; verify 404s any hash not
recorded, before any IBEX call — which also kills the garbage-hash IBEX
amplification vector.

B3: verify gets its own rate-limit bucket (60/min) — LUD-21 polling can
no longer starve the payment callback's shared 120/min budget.

N1: settled is now the single strict signal (state.id === 1, matching
payment-status-checker); settleDateUtc no longer gates preimage release.
N2: settled results (immutable) served from a bounded in-process cache.
N3: LNURL convention — HTTP 200 with status:ERROR body, not 404 (some
wallet libs treat non-2xx as transport failure).
N4: outcome logging with hash prefixes on the verify route.
Nits: hash lowercased before lookups; misleading "unguessable capability"
comment corrected; spec relocated to routes/ with alias-based mocks.

Tests 5 -> 12, including the previously-vacuous preimage-suppression case
(now asserts a PRESENT preimage is withheld while OPEN), thrown-rejection
path, scope-guard no-IBEX-call, ACCEPTED-state-with-settleDateUtc not
settled, cache single-call, and case normalization. 102 webhook-server
tests green.
@islandbitcoin

Copy link
Copy Markdown
Contributor Author

Addressed the full adversarial review in the latest commit:

  • B1 — stray ops scripts were already removed (commit c7acb08); the review raced the push.
  • B2 — verify is now scoped to proxy-issued invoices: hashes recorded in a new LnurlInvoiceModel (unique, 30d TTL) at callback time; anything else gets Not found before any IBEX call. The false "unguessable capability" premise is gone from code + comments.
  • B3 — dedicated 60/min rate bucket for verify; polling can't starve the payment callback.
  • N1 — strict settled gate: state.id === 1 only (matches payment-status-checker); settleDateUtc no longer releases preimages.
  • N2 — bounded in-process cache for (immutable) settled results.
  • N3 — LNURL-conventional HTTP 200 + status:"ERROR" body instead of 404.
  • N4 — outcome logging with hash prefixes.
  • N5 — tests 5→12: the preimage-suppression case now uses a present preimage on an OPEN invoice (was vacuous), plus thrown-rejection, scope-guard, ACCEPTED+settleDateUtc≠settled, cache, and case-normalization coverage.
  • Nits: hash lowercased; spec relocated to routes/ with alias mocks.

Not taken: supertest route-order pin (no supertest harness in repo — route order verified manually; :username matches single segments so /verify/<hash> can't collide).

🤖 Generated with Claude Code

@islandbitcoin islandbitcoin merged commit 40aa7eb into main Jul 7, 2026
14 checks passed
@islandbitcoin islandbitcoin deleted the jabariennis/lud21-lnurl-verify branch July 7, 2026 16:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants