mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user