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
+102 -39
View File
@@ -5,6 +5,7 @@
import {
AmbiguousMutationError,
mergeNotes,
newSecretsOf,
noteK1,
probeBurnedNote,
requireNoteK1,
@@ -15,7 +16,7 @@ import {
} from 'lnurlcash-kit'
import type {Bearer, NewBearer} from '../types'
import type {FundOperationOptions} from './shared'
import {assertFundOwner, UncertainOutcomeError} from './shared'
import {assertFundOwner, probeMutationOutput, UncertainOutcomeError} from './shared'
// the changeset stores apply after a mutation: `note`/`change` BEFORE
// `consumed` - the mint call already burned every consumed input
@@ -151,38 +152,78 @@ export const ensureExactAmount = async (
changeSignature = parts.changeSignature
partVerified = true
} catch (err) {
if (!(err instanceof AmbiguousMutationError)) throw err
// the split request may have landed despite the failure - probe one
// input before deciding what the carried secrets are worth
const outcome = await probeBurnedNote(base.url, options)
if (outcome === 'live') throw err // nothing burned - a plain failure
if (outcome === 'unknown') {
// can't tell: surface both possible outputs unverified WITHOUT
// consuming the inputs, and stop here rather than spend from limbo
throw new UncertainOutcomeError(
'The split may have gone through but could not be confirmed - the possible outputs must be tracked unverified alongside the originals until refreshed.',
[
{
url: withNewK1(base.url, err.newSecrets[0], amountMsat),
callback: base.callback,
amount: amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
},
{
url: withNewK1(base.url, err.newSecrets[1], total - amountMsat),
callback: base.callback,
amount: total - amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
},
],
)
if (err instanceof AmbiguousMutationError) {
// the split request may have landed despite the failure - probe one
// input before deciding what the carried secrets are worth
const outcome = await probeBurnedNote(base.url, options)
if (outcome === 'live') throw err // nothing burned - a plain failure
if (outcome === 'unknown') {
// can't tell: surface both possible outputs unverified WITHOUT
// consuming the inputs, and stop here rather than spend from limbo
throw new UncertainOutcomeError(
'The split may have gone through but could not be confirmed - the possible outputs must be tracked unverified alongside the originals until refreshed.',
[
{
url: withNewK1(base.url, err.newSecrets[0], amountMsat),
callback: base.callback,
amount: amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
},
{
url: withNewK1(base.url, err.newSecrets[1], total - amountMsat),
callback: base.callback,
amount: total - amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
},
],
)
}
// 'gone': the burn landed - the carried secrets are the only money
partK1 = err.newSecrets[0]
changeK1 = err.newSecrets[1]
rescued = true
} else {
// A classified refusal can still be a LANDED split: 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 kit attaches the fresh output secrets to every refusal - probe
// one output before deciding they are worthless.
const carried = newSecretsOf(err)
if (carried.length !== 2) throw err
const outcome = await probeMutationOutput(base.url, carried[0], options)
if (outcome === 'absent') throw err // never landed - a plain refusal
if (outcome === 'unknown') {
// can't tell whether the refusal named a retry - same limbo as
// the ambiguous case above: track the possible outputs, consume
// nothing, stop here
throw new UncertainOutcomeError(
'The split was refused, but the refusal may have named a retry of a split that already landed - the possible outputs must be tracked unverified alongside the originals until refreshed.',
[
{
url: withNewK1(base.url, carried[0], amountMsat),
callback: base.callback,
amount: amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
},
{
url: withNewK1(base.url, carried[1], total - amountMsat),
callback: base.callback,
amount: total - amountMsat,
verified: false,
mintPubkey: base.mintPubkey,
},
],
)
}
// 'live': the split landed and this answer was its retried twin -
// the carried secrets are the only money left
partK1 = carried[0]
changeK1 = carried[1]
rescued = true
}
// 'gone': the burn landed - the carried secrets are the only money
partK1 = err.newSecrets[0]
changeK1 = err.newSecrets[1]
rescued = true
}
const note: NewBearer = {
url: withNewK1(base.url, partK1, amountMsat, partSignature),
@@ -261,15 +302,38 @@ const mergeAmbiguitySafe = async (
const merged = await mergeNotes(base.callback, k1s, options)
return {k1: merged.k1, signature: merged.signature, rescued: false}
} catch (err) {
if (!(err instanceof AmbiguousMutationError)) throw err
const outcome = await probeBurnedNote(base.url, options)
if (outcome === 'live') throw err // nothing burned - a plain failure
if (err instanceof AmbiguousMutationError) {
const outcome = await probeBurnedNote(base.url, options)
if (outcome === 'live') throw err // nothing burned - a plain failure
if (outcome === 'unknown') {
throw new UncertainOutcomeError(
'The merge may have gone through but could not be confirmed - the possible combined note must be tracked unverified alongside the originals until refreshed.',
[
{
url: withNewK1(base.url, err.newSecrets[0], total),
callback: base.callback,
amount: total,
verified: false,
mintPubkey: base.mintPubkey,
},
],
)
}
// 'gone': the burn landed - the carried secret is the only money left
return {k1: err.newSecrets[0], rescued: true}
}
// same retry-refusal rescue as the split path: the refusal may name a
// merge that already landed - probe the would-be combined note
const carried = newSecretsOf(err)
if (carried.length !== 1) throw err
const outcome = await probeMutationOutput(base.url, carried[0], options)
if (outcome === 'absent') throw err
if (outcome === 'unknown') {
throw new UncertainOutcomeError(
'The merge may have gone through but could not be confirmed - the possible combined note must be tracked unverified alongside the originals until refreshed.',
'The merge was refused, but the refusal may have named a retry of a merge that already landed - the possible combined note must be tracked unverified alongside the originals until refreshed.',
[
{
url: withNewK1(base.url, err.newSecrets[0], total),
url: withNewK1(base.url, carried[0], total),
callback: base.callback,
amount: total,
verified: false,
@@ -278,7 +342,6 @@ const mergeAmbiguitySafe = async (
],
)
}
// 'gone': the burn landed - the carried secret is the only money left
return {k1: err.newSecrets[0], rescued: true}
return {k1: carried[0], rescued: true}
}
}
+38 -2
View File
@@ -34,6 +34,8 @@ import {
lightningAddressUsername,
mintAddressUrl,
namesMintOutput,
newSecretsOf,
NoteSpentError,
probeBurnedNote,
requestInvoice,
resolveMintInput,
@@ -47,7 +49,7 @@ import type {NewBearer} from '../types'
import {ceilMsatToSat} from '../units'
import type {PollOptions} from './shared'
import type {FundOperationOptions} from './shared'
import {PollAbortedError, assertFundOwner, pollUntil, pollVerifyUntilSettled} from './shared'
import {PollAbortedError, assertFundOwner, pollUntil, pollVerifyUntilSettled, probeMutationOutput} from './shared'
export type PreparedMint = {
invoice: string
@@ -361,7 +363,41 @@ export const claimFromPreimage = async (
rotationError = err.message
}
} else {
rotationError = err instanceof Error ? err.message : String(err)
// A classified 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.
// "Already spent" then PROVES the preimage note is burned - the
// carried fresh secret is the only money left, so probe it before
// believing the refusal.
const carried = newSecretsOf(err)
if (carried.length === 1) {
const outcome = await probeMutationOutput(declaredUrl, carried[0], options)
if (outcome === 'live') {
// the rotate landed and this answer was its retried twin
url = withNewK1(declaredUrl, carried[0], noteInfo.maxWithdrawable)
rotated = true
} else if (outcome === 'unknown') {
possibleCopy = {
url: withNewK1(declaredUrl, carried[0], noteInfo.maxWithdrawable),
callback: noteInfo.callback,
amount: noteInfo.maxWithdrawable,
verified: false,
}
if (mintPubkey) possibleCopy.mintPubkey = mintPubkey
// the preimage note itself may be burned - it cannot stay verified
base.verified = false
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.`
} else if (err instanceof NoteSpentError) {
// a genuine already-spent refusal with no live output: the note
// the service just described is burned - it cannot stay verified
base.verified = false
rotationError = err.message
} else {
rotationError = err instanceof Error ? err.message : String(err)
}
} else {
rotationError = err instanceof Error ? err.message : String(err)
}
}
}
const claimed: ClaimedNote = {note: {...base, url}, rotated}
+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