feat(lnurlcash): probe would-be outputs before believing a mutation refusal

The redeem callback is a GET and HTTP stacks retry GETs, so a mint can
execute the first attempt and refuse the repeat as an already-spent input
(LUD-25 says SHOULD replay; real mints refuse). The kit attaches the fresh
output secrets to every refusal - probeMutationOutput tells a landed
mutation from a genuine refusal. The harness wrapper reproduces the retry
against the mock.
This commit is contained in:
2026-08-31 14:03:15 +02:00
parent 4cc8e0aba2
commit 48be23dceb
2 changed files with 47 additions and 1 deletions
+28 -1
View File
@@ -2,8 +2,9 @@
// every flow that waits on a payment uses, and the uncertainty type a lost
// mutation answer surfaces as.
import {fetchInvoiceVerification} from 'lnurlcash-kit'
import {fetchInvoiceVerification, fetchNoteInfo, withNewK1} from 'lnurlcash-kit'
import type {LnurlcashOptions, VerifyResult} from 'lnurlcash-kit'
import {NoteSpentError, NoteUnknownError, noteDeclaredAmount} from 'lnurlcash-kit'
import type {NewBearer} from '../types'
// a mutation's answer was lost AND the probe could not tell whether it
@@ -36,6 +37,32 @@ export const assertFundOwner = (options: FundOperationOptions): void => {
options.assertOwner?.()
}
// what probing a would-be mutation output can tell
export type OutputProbe = 'live' | 'absent' | 'unknown'
// A refused mutation can still have LANDED: the redeem callback is a GET
// and HTTP stacks retry GETs, so the service may have executed the first
// attempt and refused this one as an already-spent input (LUD-25 says a
// byte-identical retry SHOULD get the original success replayed; real
// mints refuse instead). The kit attaches the fresh output secrets to
// every service refusal (newSecretsOf) - so before believing a refusal,
// probe one would-be output: 'live' proves the mutation landed and the
// carried secrets are the only money left, 'absent' proves the refusal is
// genuine, 'unknown' keeps it genuinely ambiguous.
export const probeMutationOutput = async (
noteUrl: string,
secret: string,
options: LnurlcashOptions = {},
): Promise<OutputProbe> => {
try {
await fetchNoteInfo(withNewK1(noteUrl, secret, noteDeclaredAmount(noteUrl) ?? 0), options)
return 'live'
} catch (err) {
if (err instanceof NoteSpentError || err instanceof NoteUnknownError) return 'absent'
return 'unknown'
}
}
export type PollOptions = {
// first delay between checks (doubles each round up to intervalCapMs)
intervalMs?: number