From 48be23dceb940b28166b242ff308600864b87dd2 Mon Sep 17 00:00:00 2001 From: protom Date: Mon, 31 Aug 2026 14:03:15 +0200 Subject: [PATCH] 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. --- src/lnurlcash/ops.testHarness.ts | 19 +++++++++++++++++++ src/lnurlcash/ops/shared.ts | 29 ++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/lnurlcash/ops.testHarness.ts b/src/lnurlcash/ops.testHarness.ts index 98b1166..ee6a1b1 100644 --- a/src/lnurlcash/ops.testHarness.ts +++ b/src/lnurlcash/ops.testHarness.ts @@ -69,3 +69,22 @@ export const expectBurned = async (instance: Mint, k1: string): Promise => } expect(instance.state.noteState(k1)).toBe('burned') } + +// a fetch wrapper that answers every /w/cb request with the response to +// its SECOND, byte-identical send - the HTTP-stack GET retry a real client +// is subject to: the mint executes the first attempt and (with the mock's +// default retriedMutation: 'refuse', which is also lnurl-mint's behavior) +// refuses the repeat as an already-spent input, which is all the caller +// ever sees +export const retryingCbFetch = (): typeof fetch => { + const impl: typeof fetch = async (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (url.includes('/w/cb')) { + const first = await fetch(input, init) + await first.arrayBuffer() + return fetch(input, init) + } + return fetch(input, init) + } + return impl +} diff --git a/src/lnurlcash/ops/shared.ts b/src/lnurlcash/ops/shared.ts index d20a70f..62f989a 100644 --- a/src/lnurlcash/ops/shared.ts +++ b/src/lnurlcash/ops/shared.ts @@ -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 => { + 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