From 50b2427b8db07692a590ff2602a034385bbac7df Mon Sep 17 00:00:00 2001 From: protom Date: Mon, 31 Aug 2026 14:03:15 +0200 Subject: [PATCH] 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. --- src/lnurlcash/ops.retry-refusal.cases.ts | 90 +++++++++++++++ src/lnurlcash/ops.test.ts | 1 + src/lnurlcash/ops/carve.ts | 141 ++++++++++++++++------- src/lnurlcash/ops/mint.ts | 40 ++++++- src/lnurlcash/ops/receiveBearer.ts | 39 ++++++- src/lnurlcash/receive.ts | 20 ++-- 6 files changed, 281 insertions(+), 50 deletions(-) create mode 100644 src/lnurlcash/ops.retry-refusal.cases.ts diff --git a/src/lnurlcash/ops.retry-refusal.cases.ts b/src/lnurlcash/ops.retry-refusal.cases.ts new file mode 100644 index 0000000..9cf73c4 --- /dev/null +++ b/src/lnurlcash/ops.retry-refusal.cases.ts @@ -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) + }) +}) diff --git a/src/lnurlcash/ops.test.ts b/src/lnurlcash/ops.test.ts index 1311cb4..97ce1bf 100644 --- a/src/lnurlcash/ops.test.ts +++ b/src/lnurlcash/ops.test.ts @@ -2,4 +2,5 @@ import './ops.carve.cases' import './ops.mint-named.cases' import './ops.mint-receive.cases' import './ops.pay.cases' +import './ops.retry-refusal.cases' import './ops.transfer.cases' diff --git a/src/lnurlcash/ops/carve.ts b/src/lnurlcash/ops/carve.ts index 780cd30..265eed2 100644 --- a/src/lnurlcash/ops/carve.ts +++ b/src/lnurlcash/ops/carve.ts @@ -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} } } diff --git a/src/lnurlcash/ops/mint.ts b/src/lnurlcash/ops/mint.ts index 5abd23b..ccc6044 100644 --- a/src/lnurlcash/ops/mint.ts +++ b/src/lnurlcash/ops/mint.ts @@ -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} diff --git a/src/lnurlcash/ops/receiveBearer.ts b/src/lnurlcash/ops/receiveBearer.ts index 13cbe87..e3ccc80 100644 --- a/src/lnurlcash/ops/receiveBearer.ts +++ b/src/lnurlcash/ops/receiveBearer.ts @@ -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 diff --git a/src/lnurlcash/receive.ts b/src/lnurlcash/receive.ts index 897cd56..4653be2 100644 --- a/src/lnurlcash/receive.ts +++ b/src/lnurlcash/receive.ts @@ -10,6 +10,7 @@ import { NoteUnknownError, PendingNoteError, } from 'lnurlcash-kit' +import type {LnurlcashOptions} from 'lnurlcash-kit' import type {Bearer, NewBearer} from './types' // 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 => { +// speak lnurlcash) - the caller should warn, not fail the receive. The +// 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 + callback: string + amount: number + }, + options: LnurlcashOptions = {}, +): Promise => { const k1 = noteK1(note.url) if (!k1 || !note.callback) { 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) }