feat: expose fund ownership assertions

This commit is contained in:
2026-08-22 16:55:23 +02:00
parent 02f901c87c
commit 845ec410e5
2 changed files with 67 additions and 10 deletions
+66 -9
View File
@@ -19,45 +19,102 @@ export class UncertainOutcomeError extends Error {
}
}
// the wait was interrupted from outside (service shutdown) - distinct
// from budget exhaustion so the caller can treat it as normal teardown
export class PollAbortedError extends Error {
constructor() {
super('The wait was interrupted by shutdown.')
this.name = 'PollAbortedError'
}
}
export type FundOperationOptions = LnurlcashOptions & {
readonly assertOwner?: () => void
}
export const assertFundOwner = (options: FundOperationOptions): void => {
options.assertOwner?.()
}
export type PollOptions = {
// first delay between checks (doubles each round up to intervalCapMs)
intervalMs?: number
intervalCapMs?: number
// total budget before giving up
maxWaitMs?: number
// aborts the wait promptly (shutdown). Only the WAIT is interruptible:
// callers pass this for work whose observation phase may outlive the
// caller - once pollVerifyUntilSettled has returned, the signal no
// longer reaches anything
signal?: AbortSignal
}
const DEFAULT_POLL: Required<PollOptions> = {
const DEFAULT_POLL: Required<Omit<PollOptions, 'signal'>> = {
intervalMs: 1000,
intervalCapMs: 5000,
maxWaitMs: 120_000
maxWaitMs: 120_000,
}
const sleep = (ms: number): Promise<void> =>
new Promise(resolve => setTimeout(resolve, ms))
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
// a sleep that ends immediately on abort instead of riding out its timer
const abortableSleep = (ms: number, signal: AbortSignal): Promise<void> =>
new Promise((resolve, reject) => {
const onAbort = (): void => {
clearTimeout(timer)
reject(new PollAbortedError())
}
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, ms)
if (signal.aborted) {
clearTimeout(timer)
reject(new PollAbortedError())
return
}
signal.addEventListener('abort', onAbort, {once: true})
})
// 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.
// 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 = async (
verifyUrl: string,
poll: PollOptions,
options: LnurlcashOptions
options: LnurlcashOptions,
): Promise<VerifyResult> => {
const {intervalMs, intervalCapMs, maxWaitMs} = {...DEFAULT_POLL, ...poll}
const {intervalMs, intervalCapMs, maxWaitMs, signal} = {
...DEFAULT_POLL,
...poll,
}
const fetchOptions: LnurlcashOptions = signal
? {
...options,
fetch: (input, init) => {
const base = options.fetch ?? globalThis.fetch
return base(input, {...init, signal})
},
}
: options
const deadline = Date.now() + maxWaitMs
let delay = intervalMs
let lastError: unknown = null
while (Date.now() < deadline) {
if (signal?.aborted) throw new PollAbortedError()
try {
const result = await fetchInvoiceVerification(verifyUrl, options)
const result = await fetchInvoiceVerification(verifyUrl, fetchOptions)
if (result.settled) return result
lastError = null
} catch (err) {
// the signal's own AbortError lands here on an interrupted fetch
if (signal?.aborted) throw new PollAbortedError()
lastError = err
}
await sleep(Math.min(delay, Math.max(0, deadline - Date.now())))
if (signal) await abortableSleep(Math.min(delay, Math.max(0, deadline - Date.now())), signal)
else await sleep(Math.min(delay, Math.max(0, deadline - Date.now())))
delay = Math.min(delay * 2, intervalCapMs)
}
if (lastError instanceof Error) {