Skip to content

Stop LUD-21 verify polling on a terminal verify error - #13

Merged
pretyflaco merged 2 commits into
mainfrom
fix/lud21-verify-terminal-error
Aug 17, 2026
Merged

pretyflaco merged 2 commits into
mainfrom
fix/lud21-verify-terminal-error

Conversation

@pretyflaco

Copy link
Copy Markdown
Collaborator

Summary

On the self-custodial (Spark) path, a genuine LUD-21 verify {"status":"ERROR"} (invoice not found/rejected) means the invoice will never settle. Previously pollVerifyStatus treated it like any transient failure — backing off 5s and re-polling lnurl.blink.sv/verify/* until the invoice expiry deadline. That's minutes of pointless polling per not-found invoice.

This makes a terminal verify error stop polling immediately, while leaving transient network/HTTP errors to back off and retry as before.

Context

This came out of a review comparing the widget's verify-polling against the runaway polling found in the BTCPay Blink plugin (blinkbitcoin/blink-wip#1123). The widget is already well-behaved — polling is bounded by the invoice expiry and cancellation is race-safe via generation tokens, so it does not have the plugin's runaway-forever or fan-out bugs. This PR closes the one remaining gap: a genuine status:ERROR should terminate polling early rather than run out the expiry clock.

Change

  • verifyLnurlPayment (canonical js/blink-lnurl.js and the widget's inline getLnurl() copy) now tags a status:ERROR throw as terminal: err.lnurlVerifyTerminal = true. Non-ok HTTP throws stay untagged (transient).
  • pollVerifyStatus stops (no reschedule) on a terminal error; transient errors keep the existing 5s backoff-and-retry.
  • Canonical and inline copies kept in parity (per AGENTS.md).

Tests

  • tests/blink-lnurl.spec.js: status:ERROR is terminal; non-ok HTTP is not.
  • tests/widget-spark.spec.js: inline-copy parity for both cases, asserted against the canonical module — closes a prior gap where the inline ERROR/non-ok path was untested.
  • tests/widget-polling.spec.js: pollVerifyStatus stops on terminal; keeps backing off/retrying on transient.
  • All 143 tests pass; ESLint clean (CI runs lint + vitest).

Compatibility

No public API / DOM / config changes. Behavior only differs for a not-found/rejected invoice (it now stops sooner). Version 1.5.0 → 1.5.1 (+ analytics widget_version).

Note: this widget redeploys to all live embeds on merge to main; the change is additive and low-risk (a strictly-earlier stop on an invoice that can never be paid).

On the self-custodial (Spark) path, a genuine verify {"status":"ERROR"}
(invoice not found/rejected) means the invoice will never settle. Previously
pollVerifyStatus treated it like a transient failure: it backed off 5s and
kept re-polling until the invoice expiry deadline (minutes of pointless
polling of lnurl.blink.sv/verify/* per not-found invoice).

verifyLnurlPayment now tags a status:ERROR throw as terminal
(err.lnurlVerifyTerminal = true), and pollVerifyStatus stops immediately on a
terminal error instead of rescheduling. Transient network/HTTP errors are
unchanged: they still back off and retry until settle or expiry.

The change is applied to BOTH the canonical js/blink-lnurl.js module and the
widget's inline copy (getLnurl()), kept in parity. Tests:
- canonical: status:ERROR is terminal, non-ok HTTP is not (blink-lnurl.spec)
- inline parity for both cases, asserted against the canonical module
  (closes a prior gap where the inline ERROR path was untested) (widget-spark.spec)
- pollVerifyStatus stops on terminal, keeps backing off on transient
  (widget-polling.spec)

No public API / DOM changes. Version 1.5.0 -> 1.5.1 (+ analytics widget_version).
All 143 tests pass; eslint clean.

@blink-dev-bot blink-dev-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

HIGH

  • js/blink-lnurl.js:285 / js/blink-pay-button.js:1642,1752 — Do not classify every LUD-21 status: ERROR response as terminal. Blink’s current verify route also returns status: ERROR when its GraphQL query throws, with the reason “We could not verify the invoice. Please try again later.” A temporary backend failure would therefore stop this widget’s only settlement detector permanently; if the invoice is paid afterward, the donor UI never observes it. LUD-21 defines ERROR as an error envelope but does not define all ERROR responses as terminal. Please either classify only an explicit stable terminal condition (for example, expired), introduce a machine-readable terminal error contract server-side, or keep retrying ambiguous ERROR responses. Add a polling test for the “Please try again later” response proving it retries.

MEDIUM

  • None.

LOW

  • None.

Validation: 143 tests passed; ESLint completed with 4 existing warnings and 0 errors; git diff --check passed.

🤖 Review by GPT-5 Codex via Blink-Claw-Bot

Blink's verify route returns status:ERROR for BOTH a terminal 'Not found'
(invoice never existed) AND transient backend failures ('Internal server
error', or a GraphQL throw => 'We could not verify the invoice. Please try
again later.'). Classifying every ERROR as terminal would permanently stop the
widget's only settlement detector on a temporary blip, and a payment landing
afterward would go unobserved.

Narrow the terminal classification to an explicit not-found reason
(isTerminalVerifyReason => /not\s*found/i). Every other status:ERROR stays
untagged and is retried (5s backoff) until settle or invoice expiry. Fails safe:
an unrecognised reason is treated as transient, never as a missed payment.
Applied to both the canonical module and the widget's inline copy (parity).

Tests: not-found is terminal; 'try again later' and 'Internal server error' are
NOT terminal (canonical + inline parity); pollVerifyStatus keeps retrying on a
transient 'try again later' status:ERROR driven end-to-end through the real
inline verifyLnurlPayment. 147 tests pass; eslint clean.
@pretyflaco

Copy link
Copy Markdown
Collaborator Author

Good catch — fixed in the latest push.

You're right that Blink's verify route returns status:ERROR for both a terminal case and transient backend failures (I confirmed against blink-lnurl-server's handler: reason: "Not found" for a genuinely missing invoice, and reason: "Internal server error" when the DB lookup or the Blink settlement query throws — the same class as the "…try again later" GraphQL-throw you cited). Classifying all of them terminal would permanently stop the widget's only settlement detector on a temporary blip and miss a later payment.

Change: the terminal tag is now gated on a stable not-found reason only:

  • isTerminalVerifyReason(reason) => /not\s*found/i. Only that sets err.lnurlVerifyTerminal = true.
  • Every other status:ERROR ("Internal server error", "…try again later", or any unrecognised reason) is thrown untagged, so pollVerifyStatus keeps its 5s backoff-and-retry until settle or invoice expiry.
  • Fails safe: an unknown/renamed reason is treated as transient (retry), never as a missed payment.
  • Applied to both the canonical module and the widget's inline copy (parity).

Tests added (incl. the one you requested):

  • blink-lnurl.spec: "Not found" => terminal; "We could not verify the invoice. Please try again later." => NOT terminal; "Internal server error" => NOT terminal; non-ok HTTP => NOT terminal.
  • widget-spark.spec: inline-copy parity for the same, asserted against the canonical module.
  • widget-polling.spec: pollVerifyStatus keeps retrying on a transient "try again later" status:ERROR, driven end-to-end through the real inline verifyLnurlPayment (proves it does not stop). Terminal not-found still stops.

147 tests pass; ESLint clean (0 errors).

@blink-dev-bot blink-dev-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

HIGH

  • None. The previous blocker is addressed: only an explicit not-found verify reason is treated as terminal, while ambiguous/transient status: ERROR responses continue backing off and retrying.

MEDIUM

  • None.

LOW

  • None.

Validation: GitHub CI is green. Locally, 147 tests passed, ESLint completed with 4 existing warnings and 0 errors, and git diff --check origin/main...HEAD passed.

🤖 Review by GPT-5 Codex via Blink-Claw-Bot

@pretyflaco
pretyflaco merged commit abb69fc into main Aug 17, 2026
2 checks passed
@pretyflaco
pretyflaco deleted the fix/lud21-verify-terminal-error branch August 17, 2026 22:07
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.

2 participants