fix(lnurlcash): rescue landed mutations from retry refusals

A split, merge or rotate whose answer is 'Invalid or already spent k1.'
may be the refusal of a retried request that already LANDED - believing
it strands the fresh outputs server-side and desyncs the wallet (the
input is burned at the mint but still spendable locally, so every later
operation fails). All three flows now probe a would-be output when a
refusal carries fresh secrets: live outputs are adopted (or surfaced
unverified when the probe can't tell), a genuine refusal still fails.
This commit is contained in:
2026-08-31 14:03:15 +02:00
parent 48be23dceb
commit 50b2427b8d
6 changed files with 281 additions and 50 deletions
+37 -2
View File
@@ -9,6 +9,7 @@ import {
NoteSpentError,
NoteUnknownError,
PendingNoteError,
newSecretsOf,
probeBurnedNote,
withNewK1,
} from 'lnurlcash-kit'
@@ -16,7 +17,7 @@ import type {Bearer, NewBearer} from '../types'
import {receiveNote, secureReceivedNote} from '../receive'
import type {ClaimedNote} from './mint'
import type {FundOperationOptions} from './shared'
import {assertFundOwner} from './shared'
import {assertFundOwner, probeMutationOutput} from './shared'
// NoteSpentError / NoteUnknownError / PendingNoteError from the service are
// definitive and propagate; an unreachable service still yields the note,
@@ -32,9 +33,43 @@ export const receiveBearer = async (
}
try {
assertFundOwner(options)
const rotatedUrl = await secureReceivedNote(note)
const rotatedUrl = await secureReceivedNote(note, options)
return {note: {...note, url: rotatedUrl}, rotated: true}
} catch (err) {
// A classified rotate refusal can still be a LANDED rotate: the
// 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. The refusal then proves the sender's copy is
// burned and the carried fresh secret is the only money left - probe
// it before believing the refusal.
const carried = newSecretsOf(err)
if (carried.length === 1 && !(err instanceof AmbiguousMutationError)) {
const outcome = await probeMutationOutput(note.url, carried[0], options)
if (outcome === 'live') {
return {
note: {...note, url: withNewK1(note.url, carried[0], note.amount)},
rotated: true,
}
}
if (outcome === 'unknown') {
const possibleCopy: NewBearer = {
url: withNewK1(note.url, carried[0], note.amount),
callback: note.callback,
amount: note.amount,
verified: false,
}
if (note.mintPubkey) possibleCopy.mintPubkey = note.mintPubkey
return {
// the sender's copy may be burned - it cannot stay verified
note: {...note, verified: false},
rotated: false,
possibleCopy,
rotationError: `${err instanceof Error ? err.message : String(err)} The refusal may have named a retry of a rotation that already landed - the possible rotated copy is tracked unverified alongside this one.`,
}
}
// 'absent': the refusal is genuine - fall through to the distinct
// handling below
}
// a definitive service state (dead/unknown/locked mid-melt) is not a
// rotation failure to warn about - it tells the holder what this note
// actually is, so it propagates distinctly