refactor(lnurlcash): generalize bounded polling beyond verify endpoints

pollVerifyUntilSettled keeps its exact behavior and messages, reimplemented
on a generic pollUntil so flows that watch something other than a LUD-21
verify (a named mint output appearing) share the same backoff, budget and
abort semantics.
This commit is contained in:
2026-08-31 12:22:30 +02:00
parent bb147faef1
commit 9f4ae3b0f5
+33 -12
View File
@@ -76,16 +76,17 @@ const abortableSleep = (ms: number, signal: AbortSignal): Promise<void> =>
signal.addEventListener('abort', onAbort, {once: true}) signal.addEventListener('abort', onAbort, {once: true})
}) })
// polls a LUD-21/LUD-25 verify endpoint until it reports settled, with // polls `check` until it yields a value, with backoff, inside a total
// backoff, inside a total time budget. A single failed check isn't fatal - // time budget. A single failed check isn't fatal - the next round tries
// the next round tries again. Returns the settled VerifyResult; throws on // again. Returns the first value; throws on budget exhaustion, or
// budget exhaustion, or PollAbortedError when the caller's signal fires // PollAbortedError when the caller's signal fires (a hung fetch is
// (a hung fetch is interrupted too: the signal is bound into the request). // interrupted too: the signal is bound into the request).
export const pollVerifyUntilSettled = async ( export const pollUntil = async <T>(
verifyUrl: string, check: (options: LnurlcashOptions) => Promise<T | null>,
exhausted: string,
poll: PollOptions, poll: PollOptions,
options: LnurlcashOptions, options: LnurlcashOptions,
): Promise<VerifyResult> => { ): Promise<T> => {
const {intervalMs, intervalCapMs, maxWaitMs, signal} = { const {intervalMs, intervalCapMs, maxWaitMs, signal} = {
...DEFAULT_POLL, ...DEFAULT_POLL,
...poll, ...poll,
@@ -105,8 +106,8 @@ export const pollVerifyUntilSettled = async (
while (Date.now() < deadline) { while (Date.now() < deadline) {
if (signal?.aborted) throw new PollAbortedError() if (signal?.aborted) throw new PollAbortedError()
try { try {
const result = await fetchInvoiceVerification(verifyUrl, fetchOptions) const result = await check(fetchOptions)
if (result.settled) return result if (result !== null) return result
lastError = null lastError = null
} catch (err) { } catch (err) {
// the signal's own AbortError lands here on an interrupted fetch // the signal's own AbortError lands here on an interrupted fetch
@@ -118,7 +119,27 @@ export const pollVerifyUntilSettled = async (
delay = Math.min(delay * 2, intervalCapMs) delay = Math.min(delay * 2, intervalCapMs)
} }
if (lastError instanceof Error) { if (lastError instanceof Error) {
throw new Error(`Payment not confirmed: ${lastError.message}`) throw new Error(`${exhausted}: ${lastError.message}`)
} }
throw new Error('Payment not confirmed within the time budget.') throw new Error(`${exhausted} within the time budget.`)
} }
// polls a LUD-21/LUD-25 verify endpoint until it reports settled, with
// backoff, inside a total time budget. A single failed check isn't fatal -
// the next round tries again. Returns the settled VerifyResult; throws on
// budget exhaustion, or PollAbortedError when the caller's signal fires
// (a hung fetch is interrupted too: the signal is bound into the request).
export const pollVerifyUntilSettled = (
verifyUrl: string,
poll: PollOptions,
options: LnurlcashOptions,
): Promise<VerifyResult> =>
pollUntil(
async (fetchOptions) => {
const result = await fetchInvoiceVerification(verifyUrl, fetchOptions)
return result.settled ? result : null
},
'Payment not confirmed',
poll,
options,
)