mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
feat: expose fund ownership assertions
This commit is contained in:
@@ -41,5 +41,5 @@ export type {
|
|||||||
TransferOptions,
|
TransferOptions,
|
||||||
TransferOutcome,
|
TransferOutcome,
|
||||||
TransferQuote,
|
TransferQuote,
|
||||||
TransferResult
|
TransferResult,
|
||||||
} from './ops/transfer'
|
} from './ops/transfer'
|
||||||
|
|||||||
@@ -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 = {
|
export type PollOptions = {
|
||||||
// first delay between checks (doubles each round up to intervalCapMs)
|
// first delay between checks (doubles each round up to intervalCapMs)
|
||||||
intervalMs?: number
|
intervalMs?: number
|
||||||
intervalCapMs?: number
|
intervalCapMs?: number
|
||||||
// total budget before giving up
|
// total budget before giving up
|
||||||
maxWaitMs?: number
|
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,
|
intervalMs: 1000,
|
||||||
intervalCapMs: 5000,
|
intervalCapMs: 5000,
|
||||||
maxWaitMs: 120_000
|
maxWaitMs: 120_000,
|
||||||
}
|
}
|
||||||
|
|
||||||
const sleep = (ms: number): Promise<void> =>
|
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
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
|
// 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 -
|
// backoff, inside a total time budget. A single failed check isn't fatal -
|
||||||
// the next round tries again. Returns the settled VerifyResult; throws on
|
// 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 (
|
export const pollVerifyUntilSettled = async (
|
||||||
verifyUrl: string,
|
verifyUrl: string,
|
||||||
poll: PollOptions,
|
poll: PollOptions,
|
||||||
options: LnurlcashOptions
|
options: LnurlcashOptions,
|
||||||
): Promise<VerifyResult> => {
|
): 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
|
const deadline = Date.now() + maxWaitMs
|
||||||
let delay = intervalMs
|
let delay = intervalMs
|
||||||
let lastError: unknown = null
|
let lastError: unknown = null
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
|
if (signal?.aborted) throw new PollAbortedError()
|
||||||
try {
|
try {
|
||||||
const result = await fetchInvoiceVerification(verifyUrl, options)
|
const result = await fetchInvoiceVerification(verifyUrl, fetchOptions)
|
||||||
if (result.settled) return result
|
if (result.settled) return result
|
||||||
lastError = null
|
lastError = null
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// the signal's own AbortError lands here on an interrupted fetch
|
||||||
|
if (signal?.aborted) throw new PollAbortedError()
|
||||||
lastError = err
|
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)
|
delay = Math.min(delay * 2, intervalCapMs)
|
||||||
}
|
}
|
||||||
if (lastError instanceof Error) {
|
if (lastError instanceof Error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user