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
+90
View File
@@ -0,0 +1,90 @@
import {describe, expect, it} from 'vitest'
import {noteK1} from 'lnurlcash-kit'
import {ensureExactAmount, receiveBearer} from './ops'
import {claimFromPreimage, prepareMint} from './ops/mint'
import {requiredValue} from './test-utils'
import {
makeBearer,
mint,
noteUrl,
retryingCbFetch,
secret,
settleLastInvoice,
} from './ops.testHarness'
// The redeem callback is a GET, and HTTP stacks retry GETs - so a mint
// can execute the first attempt of a mutation and refuse the byte-identical
// repeat as an already-spent input, which is the only answer the wallet
// ever sees. Every mutation that carries fresh secrets must probe the
// would-be outputs before believing such a refusal: landed means the
// carried secrets are the only money left.
describe('mutation retry refusals', () => {
it('rescues the outputs when the split answer is a retry refusal', async () => {
const instance = await mint()
const k1 = secret('60')
const bearer = await makeBearer(instance, k1, 50_000)
const result = await ensureExactAmount([bearer], 25_000, {fetch: retryingCbFetch()})
// the split landed on the first (unseen) attempt: the input is burned
// and both outputs are live under the secrets the wallet carried
expect(instance.state.noteState(k1)).toBe('burned')
expect(result.consumed).toHaveLength(1)
const partK1 = requiredValue(noteK1(result.note.url))
expect(partK1).not.toBe(k1)
expect(result.note.amount).toBe(25_000)
expect(instance.state.noteState(partK1)).toBe('outstanding')
const changeK1 = requiredValue(noteK1(requiredValue(result.change).url))
expect(instance.state.noteState(changeK1)).toBe('outstanding')
})
it('rescues the combined note when the merge answer is a retry refusal', async () => {
const instance = await mint()
const a = await makeBearer(instance, secret('61'), 20_000)
const b = await makeBearer(instance, secret('62'), 5_000)
const result = await ensureExactAmount([a, b], 25_000, {fetch: retryingCbFetch()})
expect(result.consumed).toHaveLength(2)
expect(instance.state.noteState(secret('61'))).toBe('burned')
expect(instance.state.noteState(secret('62'))).toBe('burned')
const mergedK1 = requiredValue(noteK1(result.note.url))
expect(result.note.amount).toBe(25_000)
expect(instance.state.noteState(mergedK1)).toBe('outstanding')
})
it('adopts the fresh secret when the claim rotate answer is a retry refusal', async () => {
const instance = await mint({testHooks: true})
const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000)
const preimage = await settleLastInvoice(instance)
const claimed = await claimFromPreimage(prepared, preimage, {fetch: retryingCbFetch()})
expect(claimed.rotated).toBe(true)
expect(claimed.note.verified).toBe(true)
expect(instance.state.noteState(preimage)).toBe('burned')
const k1 = requiredValue(noteK1(claimed.note.url))
expect(k1).not.toBe(preimage)
expect(instance.state.noteState(k1)).toBe('outstanding')
})
it('adopts the fresh secret when the receive rotate answer is a retry refusal', async () => {
const instance = await mint()
const senderK1 = secret('63')
instance.state.creditNote(senderK1, 21_000)
const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], {
fetch: retryingCbFetch(),
})
expect(received.rotated).toBe(true)
expect(received.note.amount).toBe(21_000)
expect(instance.state.noteState(senderK1)).toBe('burned')
const k1 = requiredValue(noteK1(received.note.url))
expect(k1).not.toBe(senderK1)
expect(instance.state.noteState(k1)).toBe('outstanding')
})
it('still fails a genuine refusal - a note spent before the split', async () => {
const instance = await mint()
const k1 = secret('64')
const bearer = await makeBearer(instance, k1, 50_000)
instance.state.settleMelt(k1) // burned elsewhere, before we ever asked
await expect(
ensureExactAmount([bearer], 25_000, {fetch: retryingCbFetch()}),
).rejects.toThrow(/spent/i)
})
})
+1
View File
@@ -2,4 +2,5 @@ import './ops.carve.cases'
import './ops.mint-named.cases' import './ops.mint-named.cases'
import './ops.mint-receive.cases' import './ops.mint-receive.cases'
import './ops.pay.cases' import './ops.pay.cases'
import './ops.retry-refusal.cases'
import './ops.transfer.cases' import './ops.transfer.cases'
+66 -3
View File
@@ -5,6 +5,7 @@
import { import {
AmbiguousMutationError, AmbiguousMutationError,
mergeNotes, mergeNotes,
newSecretsOf,
noteK1, noteK1,
probeBurnedNote, probeBurnedNote,
requireNoteK1, requireNoteK1,
@@ -15,7 +16,7 @@ import {
} from 'lnurlcash-kit' } from 'lnurlcash-kit'
import type {Bearer, NewBearer} from '../types' import type {Bearer, NewBearer} from '../types'
import type {FundOperationOptions} from './shared' 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 // the changeset stores apply after a mutation: `note`/`change` BEFORE
// `consumed` - the mint call already burned every consumed input // `consumed` - the mint call already burned every consumed input
@@ -151,7 +152,7 @@ export const ensureExactAmount = async (
changeSignature = parts.changeSignature changeSignature = parts.changeSignature
partVerified = true partVerified = true
} catch (err) { } catch (err) {
if (!(err instanceof AmbiguousMutationError)) throw err if (err instanceof AmbiguousMutationError) {
// the split request may have landed despite the failure - probe one // the split request may have landed despite the failure - probe one
// input before deciding what the carried secrets are worth // input before deciding what the carried secrets are worth
const outcome = await probeBurnedNote(base.url, options) const outcome = await probeBurnedNote(base.url, options)
@@ -183,6 +184,46 @@ export const ensureExactAmount = async (
partK1 = err.newSecrets[0] partK1 = err.newSecrets[0]
changeK1 = err.newSecrets[1] changeK1 = err.newSecrets[1]
rescued = true 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
}
} }
const note: NewBearer = { const note: NewBearer = {
url: withNewK1(base.url, partK1, amountMsat, partSignature), url: withNewK1(base.url, partK1, amountMsat, partSignature),
@@ -261,7 +302,7 @@ const mergeAmbiguitySafe = async (
const merged = await mergeNotes(base.callback, k1s, options) const merged = await mergeNotes(base.callback, k1s, options)
return {k1: merged.k1, signature: merged.signature, rescued: false} return {k1: merged.k1, signature: merged.signature, rescued: false}
} catch (err) { } catch (err) {
if (!(err instanceof AmbiguousMutationError)) throw err if (err instanceof AmbiguousMutationError) {
const outcome = await probeBurnedNote(base.url, options) const outcome = await probeBurnedNote(base.url, options)
if (outcome === 'live') throw err // nothing burned - a plain failure if (outcome === 'live') throw err // nothing burned - a plain failure
if (outcome === 'unknown') { if (outcome === 'unknown') {
@@ -281,4 +322,26 @@ const mergeAmbiguitySafe = async (
// 'gone': the burn landed - the carried secret is the only money left // 'gone': the burn landed - the carried secret is the only money left
return {k1: err.newSecrets[0], rescued: true} 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 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, carried[0], total),
callback: base.callback,
amount: total,
verified: false,
mintPubkey: base.mintPubkey,
},
],
)
}
return {k1: carried[0], rescued: true}
}
} }
+37 -1
View File
@@ -34,6 +34,8 @@ import {
lightningAddressUsername, lightningAddressUsername,
mintAddressUrl, mintAddressUrl,
namesMintOutput, namesMintOutput,
newSecretsOf,
NoteSpentError,
probeBurnedNote, probeBurnedNote,
requestInvoice, requestInvoice,
resolveMintInput, resolveMintInput,
@@ -47,7 +49,7 @@ import type {NewBearer} from '../types'
import {ceilMsatToSat} from '../units' import {ceilMsatToSat} from '../units'
import type {PollOptions} from './shared' import type {PollOptions} from './shared'
import type {FundOperationOptions} 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 = { export type PreparedMint = {
invoice: string invoice: string
@@ -360,9 +362,43 @@ export const claimFromPreimage = async (
} else { } else {
rotationError = err.message rotationError = err.message
} }
} else {
// 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 { } else {
rotationError = err instanceof Error ? err.message : String(err) rotationError = err instanceof Error ? err.message : String(err)
} }
} else {
rotationError = err instanceof Error ? err.message : String(err)
}
}
} }
const claimed: ClaimedNote = {note: {...base, url}, rotated} const claimed: ClaimedNote = {note: {...base, url}, rotated}
if (possibleCopy) claimed.possibleCopy = possibleCopy if (possibleCopy) claimed.possibleCopy = possibleCopy
+37 -2
View File
@@ -9,6 +9,7 @@ import {
NoteSpentError, NoteSpentError,
NoteUnknownError, NoteUnknownError,
PendingNoteError, PendingNoteError,
newSecretsOf,
probeBurnedNote, probeBurnedNote,
withNewK1, withNewK1,
} from 'lnurlcash-kit' } from 'lnurlcash-kit'
@@ -16,7 +17,7 @@ import type {Bearer, NewBearer} from '../types'
import {receiveNote, secureReceivedNote} from '../receive' import {receiveNote, secureReceivedNote} from '../receive'
import type {ClaimedNote} from './mint' import type {ClaimedNote} from './mint'
import type {FundOperationOptions} from './shared' import type {FundOperationOptions} from './shared'
import {assertFundOwner} from './shared' import {assertFundOwner, probeMutationOutput} from './shared'
// NoteSpentError / NoteUnknownError / PendingNoteError from the service are // NoteSpentError / NoteUnknownError / PendingNoteError from the service are
// definitive and propagate; an unreachable service still yields the note, // definitive and propagate; an unreachable service still yields the note,
@@ -32,9 +33,43 @@ export const receiveBearer = async (
} }
try { try {
assertFundOwner(options) assertFundOwner(options)
const rotatedUrl = await secureReceivedNote(note) const rotatedUrl = await secureReceivedNote(note, options)
return {note: {...note, url: rotatedUrl}, rotated: true} return {note: {...note, url: rotatedUrl}, rotated: true}
} catch (err) { } 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 // a definitive service state (dead/unknown/locked mid-melt) is not a
// rotation failure to warn about - it tells the holder what this note // rotation failure to warn about - it tells the holder what this note
// actually is, so it propagates distinctly // actually is, so it propagates distinctly
+10 -4
View File
@@ -10,6 +10,7 @@ import {
NoteUnknownError, NoteUnknownError,
PendingNoteError, PendingNoteError,
} from 'lnurlcash-kit' } from 'lnurlcash-kit'
import type {LnurlcashOptions} from 'lnurlcash-kit'
import type {Bearer, NewBearer} from './types' import type {Bearer, NewBearer} from './types'
// shared by Scan and Paste: resolve whatever came in to a note URL, ask the // shared by Scan and Paste: resolve whatever came in to a note URL, ask the
@@ -65,16 +66,21 @@ export const receiveNote = async (input: string, existing: Bearer[]): Promise<Ne
// k1 on the wire) still knows the old secret - a rotate burns it and mints // k1 on the wire) still knows the old secret - a rotate burns it and mints
// a fresh one only this wallet knows. Returns the updated note URL. Throws // a fresh one only this wallet knows. Returns the updated note URL. Throws
// when the service refuses (e.g. a plain LUD-03 withdraw link that doesn't // when the service refuses (e.g. a plain LUD-03 withdraw link that doesn't
// speak lnurlcash) - the caller should warn, not fail the receive. // speak lnurlcash) - the caller should warn, not fail the receive. The
export const secureReceivedNote = async (note: { // error carries the fresh secret (kit's newSecretsOf) so the caller can
// rescue a rotate that landed despite its refusal.
export const secureReceivedNote = async (
note: {
url: string url: string
callback: string callback: string
amount: number amount: number
}): Promise<string> => { },
options: LnurlcashOptions = {},
): Promise<string> => {
const k1 = noteK1(note.url) const k1 = noteK1(note.url)
if (!k1 || !note.callback) { if (!k1 || !note.callback) {
throw new Error('Note has no callback to rotate against yet.') throw new Error('Note has no callback to rotate against yet.')
} }
const result = await rotateNote(note.callback, k1) const result = await rotateNote(note.callback, k1, options)
return withNewK1(note.url, result.k1, note.amount, result.signature) return withNewK1(note.url, result.k1, note.amount, result.signature)
} }